@techierathore/techieflow 1.1.0 → 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/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +4 -4
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +1 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +15 -7
- 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/guard-db.sh +106 -9
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- 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/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +1 -1
- package/.tfcore/tasks/day1-brownfield.md +2 -2
- package/.tfcore/tasks/day1-greenfield.md +4 -4
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +1 -0
- package/.tfcore/tasks/mockups.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +6 -2
- package/.tfcore/telemetry/tf-metrics.sh +19 -0
- package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/html-render-shell.md +2 -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-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-build-list.py +16 -1
- package/.tfcore/utils/tf-build.sh +75 -0
- package/.tfcore/utils/tf-checklist-edit.py +21 -3
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +6 -2
- package/.tfcore/utils/tf-doc-check.py +83 -21
- package/.tfcore/utils/tf-doc-tests.sh +40 -3
- package/.tfcore/utils/tf-emit.sh +40 -10
- package/.tfcore/utils/tf-fix-close.sh +13 -7
- package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +355 -27
- package/.tfcore/utils/tf-mockup-parity.sh +14 -3
- package/.tfcore/utils/tf-phase.sh +24 -4
- package/.tfcore/utils/tf-render-html.py +19 -3
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-facts.py +90 -15
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +12 -1
- package/.tfcore/utils/tf-verify-boot.sh +274 -19
- package/.tfcore/utils/tf-verify-emit.sh +37 -17
- package/.tfcore/utils/tf-verify-list.py +219 -20
- 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 +178 -20
- package/.tfcore/utils/tf-verify-screens.sh +17 -4
- package/.tfcore/utils/tf-verify-tests.sh +107 -17
- package/.tfcore/utils/tf-verify-verdict.py +118 -15
- 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_feedback.py +79 -16
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +2 -2
- 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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tf-develop.sh — *develop-end-to-end: a brief in, a greenfield app out, up to UAT, unattended (2026-10-08).
|
|
3
|
+
#
|
|
4
|
+
# bash .tfcore/utils/tf-develop.sh <app-dir> --app <App> --brief <file> [--harness claude|opencode]
|
|
5
|
+
# [--model <id>] [--tier standard] [--no-push] [--dry-run] [--detach]
|
|
6
|
+
# bash .tfcore/utils/tf-develop.sh <app-dir> --report # write the Build Report again from the saved state
|
|
7
|
+
# bash .tfcore/utils/tf-develop.sh <app-dir> --resume # continue a stopped run where it stopped
|
|
8
|
+
#
|
|
9
|
+
# The flow-master's *develop-end-to-end starts this; the owner never types it. It runs four phases, each
|
|
10
|
+
# one an unattended goal run (tf-goal.sh: no questions, survives usage limits by waiting for the reset
|
|
11
|
+
# and resuming the same session):
|
|
12
|
+
# day1 *day1-greenfield <App> BRD, Architecture, mockups — no owner review stop
|
|
13
|
+
# day1-2 *day1-greenfield <App> --stage2 checklist and the remaining documents
|
|
14
|
+
# build *build-phase <App> every row built, verified inline, FIX mode on failures
|
|
15
|
+
# handoff *handoff-phase <App> UsageGuide with its smoke checklist and test users: UAT-ready
|
|
16
|
+
# While it runs, .tfcore/.session/develop.json exists and every task follows .tfcore/tasks/_develop-mode.md.
|
|
17
|
+
#
|
|
18
|
+
# GIT — the one exception to "agents never write to git", for this command only. The agent still
|
|
19
|
+
# cannot commit: its git guard is unchanged. THIS script commits after each phase, and pushes, from
|
|
20
|
+
# its own shell, never through an agent's tool. When it ends, nothing is left switched on.
|
|
21
|
+
#
|
|
22
|
+
# METRICS — after every phase it writes docs/metrics/develop-report.json and docs/<App>-Build-Report.md:
|
|
23
|
+
# per phase the wall-clock time (limit waits included), the agent's working time and its tokens (summed
|
|
24
|
+
# from that phase's run records in docs/metrics/runs.jsonl), and the totals: "built in …".
|
|
25
|
+
#
|
|
26
|
+
# Exit 0 UAT-ready · 3 the agent declared a phase BLOCKED (the owner must act; listed in the report)
|
|
27
|
+
# · 4 a phase ran out of cycles · 5 the provider refused the model · 2 usage error.
|
|
28
|
+
set -uo pipefail
|
|
29
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
30
|
+
case "${1:-}" in
|
|
31
|
+
-h|--help) sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
32
|
+
"") sed -n '2,27p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
|
|
33
|
+
esac
|
|
34
|
+
|
|
35
|
+
DIR="$(cd "$1" 2>/dev/null && pwd)" || { echo "tf-develop: no such dir: $1" >&2; exit 2; }; shift
|
|
36
|
+
APP=""; BRIEF=""; HARNESS="claude"; MODEL=""; TIER="standard"; PUSH=1; DRY=0; RESUME=0; DETACH=0; REPORT_ONLY=0
|
|
37
|
+
ARGS_IN=("$@")
|
|
38
|
+
while [[ $# -gt 0 ]]; do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--app) APP="${2:-}"; shift 2 ;;
|
|
41
|
+
--brief) BRIEF="${2:-}"; shift 2 ;;
|
|
42
|
+
--harness) HARNESS="${2:-}"; shift 2 ;;
|
|
43
|
+
--model) MODEL="${2:-}"; shift 2 ;;
|
|
44
|
+
--tier) TIER="${2:-}"; shift 2 ;;
|
|
45
|
+
--no-push) PUSH=0; shift ;;
|
|
46
|
+
--dry-run) DRY=1; shift ;;
|
|
47
|
+
--resume) RESUME=1; shift ;;
|
|
48
|
+
--detach) DETACH=1; shift ;;
|
|
49
|
+
--report) REPORT_ONLY=1; shift ;;
|
|
50
|
+
*) echo "tf-develop: unknown argument $1" >&2; exit 2 ;;
|
|
51
|
+
esac
|
|
52
|
+
done
|
|
53
|
+
# --detach: start this same run in its own session and return at once, so it outlives the agent session
|
|
54
|
+
# that started it (the flow-master's *develop-end-to-end), in a session of its own (tf_setsid, portable).
|
|
55
|
+
if [[ $DETACH -eq 1 ]]; then
|
|
56
|
+
mkdir -p "$DIR/.tfcore/.session"
|
|
57
|
+
rest=(); for a in "${ARGS_IN[@]}"; do [[ "$a" != --detach ]] && rest+=("$a"); done
|
|
58
|
+
source "$HERE/tf-portable.sh" # tf_setsid works on macOS too (a python3 or perl stand-in)
|
|
59
|
+
tf_setsid bash "$0" "$DIR" "${rest[@]}" > "$DIR/.tfcore/.session/develop.out" 2>&1 < /dev/null &
|
|
60
|
+
echo "tf-develop: started (pid $!) — follow .tfcore/.session/develop.log; the result is docs/<App>-Build-Report.md"
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
STATE="$DIR/.tfcore/.session/develop.json"
|
|
64
|
+
GOAL_SH="${TF_DEVELOP_GOAL_SH:-$DIR/.tfcore/utils/tf-goal.sh}" # tests point this at a fake
|
|
65
|
+
mkdir -p "$DIR/.tfcore/.session" "$DIR/docs/metrics"
|
|
66
|
+
ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
67
|
+
# The app folder must be the TOP of its own repository. A folder inside another repository answers
|
|
68
|
+
# rev-parse too, and `git add -A` there staged the whole outer repository (found by dev_001 on its first run).
|
|
69
|
+
own_repo() { local top; top="$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null)" || return 1
|
|
70
|
+
[[ -n "$top" && "$(cd "$top" && pwd -P)" == "$(cd "$DIR" && pwd -P)" ]]; }
|
|
71
|
+
log() { printf '[%s] tf-develop: %s\n' "$(ts)" "$*" | tee -a "$DIR/.tfcore/.session/develop.log" >&2; }
|
|
72
|
+
|
|
73
|
+
st() { python3 - "$STATE" "$@" <<'PY'
|
|
74
|
+
import json, sys
|
|
75
|
+
p, op, *a = sys.argv[1:]
|
|
76
|
+
try: d = json.load(open(p))
|
|
77
|
+
except Exception: d = {}
|
|
78
|
+
if op == "get": print(d.get(a[0], ""))
|
|
79
|
+
elif op == "init":
|
|
80
|
+
d = {"app": a[0], "brief": a[1], "harness": a[2], "model": a[3], "tier": a[4], "push": a[5] == "1", "started": a[6], "phases": []}
|
|
81
|
+
elif op == "done": print("yes" if any(x["name"] == a[0] and x["status"] == "done" for x in d.get("phases", [])) else "")
|
|
82
|
+
elif op == "attempts": print(sum(1 for x in d.get("phases", []) if x["name"] == a[0]))
|
|
83
|
+
elif op == "phase": # name status started ended exit commit pushed attempt — one entry per attempt, keyed by its number:
|
|
84
|
+
# a blocked attempt and its resume can start in the same second on a fast machine, and keying by the
|
|
85
|
+
# start made the second overwrite the first (dev_001 on the CI runner, 2026-10-09)
|
|
86
|
+
ph = d.get("phases", [])
|
|
87
|
+
new = dict(zip(("name", "status", "started", "ended", "goal_exit", "commit", "pushed", "attempt"), a))
|
|
88
|
+
hit = [i for i, x in enumerate(ph) if x["name"] == a[0] and str(x.get("attempt", "")) == a[7]]
|
|
89
|
+
if hit: ph[hit[0]] = new
|
|
90
|
+
else: ph.append(new)
|
|
91
|
+
d["phases"] = ph
|
|
92
|
+
if op in ("init", "phase"): json.dump(d, open(p, "w"), indent=1)
|
|
93
|
+
PY
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if [[ $REPORT_ONLY -eq 1 ]]; then :
|
|
97
|
+
elif [[ $RESUME -eq 1 ]]; then
|
|
98
|
+
[[ -f "$STATE" ]] || { echo "tf-develop: --resume: no $STATE" >&2; exit 2; }
|
|
99
|
+
APP="$(st get app)"; BRIEF="$(st get brief)"; HARNESS="$(st get harness)"; MODEL="$(st get model)"; TIER="$(st get tier)"
|
|
100
|
+
[[ "$(st get push)" == "False" ]] && PUSH=0
|
|
101
|
+
else
|
|
102
|
+
[[ -n "$APP" && -n "$BRIEF" ]] || { echo "tf-develop: --app <App> and --brief <file> are required" >&2; exit 2; }
|
|
103
|
+
[[ "$APP" =~ ^[A-Z][A-Za-z0-9]*$ ]] || { echo "tf-develop: --app must be PascalCase with no spaces" >&2; exit 2; }
|
|
104
|
+
[[ -f "$BRIEF" ]] || { echo "tf-develop: brief not found: $BRIEF" >&2; exit 2; }
|
|
105
|
+
own_repo || { echo "tf-develop: $DIR is not a git repository of its own (a folder inside another repository does not count) — set it up first" >&2; exit 2; }
|
|
106
|
+
if [[ $PUSH -eq 1 && -z "$(git -C "$DIR" remote 2>/dev/null)" ]]; then echo "tf-develop: $DIR has no git remote to push to — add one, or pass --no-push" >&2; exit 2; fi
|
|
107
|
+
if [[ $DRY -eq 0 ]]; then
|
|
108
|
+
[[ "$(cd "$(dirname "$BRIEF")" && pwd)/$(basename "$BRIEF")" == "$DIR/docs/$APP-Brief.md" ]] || cp "$BRIEF" "$DIR/docs/$APP-Brief.md"
|
|
109
|
+
st init "$APP" "docs/$APP-Brief.md" "$HARNESS" "$MODEL" "$TIER" "$PUSH" "$(ts)"
|
|
110
|
+
fi
|
|
111
|
+
fi
|
|
112
|
+
|
|
113
|
+
MODE="Act as the flow-master (.tfcore/agents/flow-master.md). The command is *develop-end-to-end for $APP, in develop mode: read .tfcore/tasks/_develop-mode.md first and follow it in every task — no question to the owner, no stop for an owner review; every decision the owner would make is yours, written where that rule says. The brief is docs/$APP-Brief.md."
|
|
114
|
+
goal_of() {
|
|
115
|
+
case "$1" in
|
|
116
|
+
day1) echo "$MODE Run *day1-greenfield $APP, stage 1 (.tfcore/tasks/day1-greenfield.md), with the brief as the concept, all of it. When stage 1 is complete and rendered, the goal is met: do not stop for review and do not start stage 2." ;;
|
|
117
|
+
day1-2) echo "$MODE Run *day1-greenfield $APP --stage2. The goal is met when stage 2 is complete and its status gate has run." ;;
|
|
118
|
+
build) echo "$MODE Run *build-phase $APP (.tfcore/tasks/build-phase.md) on the whole phase checklist: every row built, the verifier chained inline, FIX mode on every failing row, until every row is terminal. Create and migrate the database the Architecture names, locally, as that rule says." ;;
|
|
119
|
+
handoff) echo "$MODE Run *handoff-phase $APP (.tfcore/tasks/handoff-phase.md): the UsageGuide with its smoke checklist and test users, the guides, the HTMLs, the status gate. The goal is met when the app is ready for the owner's UAT." ;;
|
|
120
|
+
esac
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
commit_phase() { # $1 phase → prints "<sha> <pushed yes|no|off>"
|
|
124
|
+
local sha="" pushed="off"
|
|
125
|
+
own_repo || { echo "none no"; return; } # never commit into a repository the app folder merely sits inside
|
|
126
|
+
if [[ -n "$(git -C "$DIR" status --porcelain 2>/dev/null)" ]]; then
|
|
127
|
+
git -C "$DIR" add -A >/dev/null 2>&1
|
|
128
|
+
git -C "$DIR" commit -q -m "TechieFlow develop-end-to-end: $1 done — $APP" -m "Written by tf-develop.sh after the $1 phase; per-phase time and tokens in docs/metrics/develop-report.json." >/dev/null 2>&1
|
|
129
|
+
fi
|
|
130
|
+
sha="$(git -C "$DIR" rev-parse --short HEAD 2>/dev/null)"
|
|
131
|
+
if [[ $PUSH -eq 1 ]]; then
|
|
132
|
+
if git -C "$DIR" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
|
|
133
|
+
git -C "$DIR" push -q >/dev/null 2>&1 && pushed=yes || pushed=no
|
|
134
|
+
else
|
|
135
|
+
git -C "$DIR" push -q -u "$(git -C "$DIR" remote | head -1)" HEAD >/dev/null 2>&1 && pushed=yes || pushed=no
|
|
136
|
+
fi
|
|
137
|
+
fi
|
|
138
|
+
echo "${sha:-none} $pushed"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
report() { # writes docs/metrics/develop-report.json and docs/<App>-Build-Report.md from the state and runs.jsonl
|
|
142
|
+
python3 - "$STATE" "$DIR" <<'PY'
|
|
143
|
+
import datetime, json, os, sys
|
|
144
|
+
state, root = json.load(open(sys.argv[1])), sys.argv[2]
|
|
145
|
+
app = state["app"]
|
|
146
|
+
def t(s):
|
|
147
|
+
try: return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ")
|
|
148
|
+
except Exception: return None
|
|
149
|
+
runs = []
|
|
150
|
+
try:
|
|
151
|
+
for line in open(os.path.join(root, "docs", "metrics", "runs.jsonl"), encoding="utf-8", errors="replace"):
|
|
152
|
+
try: r = json.loads(line)
|
|
153
|
+
except Exception: continue
|
|
154
|
+
if r.get("kind", "run") == "run" and r.get("app") in (None, app) and r.get("started"): runs.append(r)
|
|
155
|
+
except OSError: pass
|
|
156
|
+
voided = {(r.get("cmd"), r.get("started")) for r in runs if r.get("kind") == "run-void"}
|
|
157
|
+
def hm(s):
|
|
158
|
+
s = int(s or 0); return f"{s // 3600} h {s % 3600 // 60:02d} min" if s >= 3600 else f"{s // 60} min {s % 60:02d} s"
|
|
159
|
+
NAMES = {"day1": "Day 1, stage 1 (BRD, Architecture, mockups)", "day1-2": "Day 1, stage 2 (checklist, documents)",
|
|
160
|
+
"build": "Build and verify", "handoff": "Handoff (UsageGuide, UAT-ready)"}
|
|
161
|
+
out, tot = [], {"wall_s": 0, "work_s": 0, "tokens_in": 0, "tokens_out": 0, "tokens_cache_read": 0, "tokens_cache_write": 0, "runs": 0}
|
|
162
|
+
models = {}
|
|
163
|
+
# One row per phase, summing every attempt of it (a phase stopped "blocked" and resumed is run again; its
|
|
164
|
+
# first attempts' time and tokens are part of what the phase cost). A run record belongs to the attempt
|
|
165
|
+
# whose window holds its start, the window's first second included and its last excluded: a record that
|
|
166
|
+
# starts the second the previous phase ends belongs to the next one (the first proof run counted the
|
|
167
|
+
# build's 301,201 tokens under Day 1 stage 2).
|
|
168
|
+
order, attempts = [], {}
|
|
169
|
+
for x in state.get("phases", []):
|
|
170
|
+
if x["name"] not in attempts: order.append(x["name"])
|
|
171
|
+
attempts.setdefault(x["name"], []).append(x)
|
|
172
|
+
def inside(r, wins):
|
|
173
|
+
s = t(r.get("started"))
|
|
174
|
+
return s is not None and any(a <= s < b for a, b in wins)
|
|
175
|
+
for name in order:
|
|
176
|
+
ats = attempts[name]; p = ats[-1]
|
|
177
|
+
wins = [(t(x.get("started")), t(x.get("ended")) or datetime.datetime.utcnow()) for x in ats if t(x.get("started"))]
|
|
178
|
+
rs = [r for r in runs if (r.get("cmd"), r.get("started")) not in voided and inside(r, wins)]
|
|
179
|
+
# A record's tokens are measured over its own window of the session, so a command chained inside
|
|
180
|
+
# another (devguide inside handoff-phase) is counted twice if both are summed: a record lying wholly
|
|
181
|
+
# inside another is dropped, and working time is the union of the windows, not their sum.
|
|
182
|
+
span = lambda r: (t(r.get("started")), t(r.get("ended")) or t(r.get("started")))
|
|
183
|
+
rs = [r for i, r in enumerate(rs) if not any(
|
|
184
|
+
j != i and span(o)[0] and span(r)[0] and span(o)[0] <= span(r)[0] and span(r)[1] <= span(o)[1]
|
|
185
|
+
and (span(o) != span(r) or j < i) for j, o in enumerate(rs))]
|
|
186
|
+
iv = sorted(span(r) for r in rs if span(r)[0]); union, cur = 0, None
|
|
187
|
+
for a_, b_ in iv:
|
|
188
|
+
if cur and a_ <= cur[1]: cur = (cur[0], max(cur[1], b_))
|
|
189
|
+
else:
|
|
190
|
+
if cur: union += (cur[1] - cur[0]).total_seconds()
|
|
191
|
+
cur = (a_, b_)
|
|
192
|
+
if cur: union += (cur[1] - cur[0]).total_seconds()
|
|
193
|
+
row = {"phase": name, "title": NAMES.get(name, name), "status": p.get("status"), "attempts": len(ats),
|
|
194
|
+
"started": ats[0].get("started"), "ended": p.get("ended"),
|
|
195
|
+
"wall_s": int(sum((b - a).total_seconds() for a, b in wins)), "work_s": int(union),
|
|
196
|
+
"tokens_in": sum(int(r.get("tokens_in") or 0) for r in rs), "tokens_out": sum(int(r.get("tokens_out") or 0) for r in rs),
|
|
197
|
+
"tokens_cache_read": sum(int(r.get("tokens_cache_read") or 0) for r in rs),
|
|
198
|
+
"tokens_cache_write": sum(int(r.get("tokens_cache_write") or 0) for r in rs),
|
|
199
|
+
"runs": len(rs), "commands": sorted({r.get("cmd") for r in rs if r.get("cmd")}), "commit": p.get("commit"), "pushed": p.get("pushed")}
|
|
200
|
+
for r in rs:
|
|
201
|
+
for m, n in (r.get("model_tokens_out") or {r.get("model") or "unknown": r.get("tokens_out") or 0}).items():
|
|
202
|
+
models[m] = models.get(m, 0) + int(n or 0)
|
|
203
|
+
for k in tot: tot[k] += row[k]
|
|
204
|
+
out.append(row)
|
|
205
|
+
done = [p for p in out if p["status"] == "done"]
|
|
206
|
+
ready = len(done) == 4
|
|
207
|
+
starts = [t(p["started"]) for p in out if t(p["started"])]; ends = [t(p["ended"]) for p in out if t(p["ended"])]
|
|
208
|
+
span = int((max(ends) - min(starts)).total_seconds()) if starts and ends else 0
|
|
209
|
+
rep = {"app": app, "harness": state.get("harness"), "model": state.get("model") or None, "tier": state.get("tier"),
|
|
210
|
+
"started": state.get("started"), "status": "uat-ready" if ready else (out[-1]["status"] if out else "not-started"),
|
|
211
|
+
"elapsed_s": span, "totals": tot, "tokens_out_by_model": models, "phases": out,
|
|
212
|
+
"note": "elapsed_s is first phase start to last phase end, usage-limit waits included; work_s is the agent's own run time from its run records"}
|
|
213
|
+
json.dump(rep, open(os.path.join(root, "docs", "metrics", "develop-report.json"), "w"), indent=1)
|
|
214
|
+
L = [f"# {app} — Build Report", "", "| | |", "|---|---|",
|
|
215
|
+
f"| What | {app}, developed end to end from docs/{app}-Brief.md by *develop-end-to-end |",
|
|
216
|
+
f"| Result | {'Ready for UAT' if ready else 'Stopped at ' + (out[-1]['title'] if out else 'the start') + ' (' + rep['status'] + ')'} |",
|
|
217
|
+
f"| Built in | {hm(span)} from start to finish, of which the agent worked {hm(tot['work_s'])} |",
|
|
218
|
+
f"| Harness and model | {state.get('harness')}, {', '.join(sorted(models)) or state.get('model') or 'default'} |",
|
|
219
|
+
f"| Tokens | {tot['tokens_in']:,} in, {tot['tokens_out']:,} out, {tot['tokens_cache_read']:,} read from cache, {tot['tokens_cache_write']:,} written to cache |",
|
|
220
|
+
"", "## Phases", "", "| Phase | Result | Time | Agent working | Tokens out | Commit |", "|---|---|---|---|---|---|"]
|
|
221
|
+
for p in out:
|
|
222
|
+
L.append(f"| {p['title']}{' (' + str(p['attempts']) + ' attempts)' if p['attempts'] > 1 else ''} | {p['status']} | {hm(p['wall_s'])} | {hm(p['work_s'])} | {p['tokens_out']:,} | {p['commit'] or '—'}{'' if p['pushed'] in ('yes', 'off') else ' (not pushed)'} |")
|
|
223
|
+
L += ["", "Time is the clock from the phase's start to its end, summed over its attempts, waits for a usage limit to reset included. "
|
|
224
|
+
"Built in also counts the time between attempts, while a stopped run waited to be resumed. "
|
|
225
|
+
"Agent working is the sum of the phase's run records. The same figures, for a comparison harness, are in "
|
|
226
|
+
"`docs/metrics/develop-report.json`.", ""]
|
|
227
|
+
open(os.path.join(root, "docs", f"{app}-Build-Report.md"), "w", encoding="utf-8").write("\n".join(L))
|
|
228
|
+
print(f"report: {rep['status']}, {hm(span)} elapsed, {tot['tokens_out']:,} tokens out — docs/{app}-Build-Report.md")
|
|
229
|
+
PY
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# No --model: the tier's routed model (.tfcore/routing.yaml), never the machine's own default. The proof
|
|
233
|
+
# run on OpenCode fell back to a local model server that was not running and failed every cycle.
|
|
234
|
+
if [[ -z "$MODEL" && -f "$DIR/.tfcore/utils/tf-model-pick.sh" ]]; then
|
|
235
|
+
MODEL="$(cd "$DIR" && bash .tfcore/utils/tf-model-pick.sh pick "$TIER" "$HARNESS" 2>/dev/null)"
|
|
236
|
+
[[ "$MODEL" == inherit ]] && MODEL=""
|
|
237
|
+
fi
|
|
238
|
+
# --report: write the report again from the saved state (the run's, or the finished run's), nothing else
|
|
239
|
+
if [[ $REPORT_ONLY -eq 1 ]]; then
|
|
240
|
+
[[ -f "$STATE" ]] || STATE="$DIR/.tfcore/.session/develop-done.json"
|
|
241
|
+
[[ -f "$STATE" ]] || { echo "tf-develop: --report: no develop run recorded here" >&2; exit 2; }
|
|
242
|
+
report; exit 0
|
|
243
|
+
fi
|
|
244
|
+
GOAL_ARGS=(--harness "$HARNESS" --tier "$TIER"); [[ -n "$MODEL" ]] && GOAL_ARGS+=(--model "$MODEL")
|
|
245
|
+
for phase in day1 day1-2 build handoff; do
|
|
246
|
+
if [[ $DRY -eq 1 ]]; then echo "$phase: bash $GOAL_SH ${GOAL_ARGS[*]} $DIR \"$(goal_of "$phase" | cut -c1-90)…\"; then commit$([[ $PUSH -eq 1 ]] && echo ' and push')"; continue; fi
|
|
247
|
+
[[ -n "$(st done "$phase")" ]] && { log "$phase already done — skipped"; continue; }
|
|
248
|
+
start="$(ts)"; log "$phase started"; attempt=$(( $(st attempts "$phase") + 1 ))
|
|
249
|
+
st phase "$phase" running "$start" "" "" "" "" "$attempt"
|
|
250
|
+
bash "$GOAL_SH" "${GOAL_ARGS[@]}" "$DIR" "$(goal_of "$phase")"; rc=$?
|
|
251
|
+
status=done; [[ $rc -eq 3 ]] && status=blocked; [[ $rc -ne 0 && $rc -ne 3 ]] && status="failed (exit $rc)"
|
|
252
|
+
end="$(ts)"
|
|
253
|
+
st phase "$phase" "$status" "$start" "$end" "$rc" "" "" "$attempt"
|
|
254
|
+
report >/dev/null # the phase's figures go into its own commit
|
|
255
|
+
read -r sha pushed <<<"$(commit_phase "$phase")"
|
|
256
|
+
st phase "$phase" "$status" "$start" "$end" "$rc" "$sha" "$pushed" "$attempt"
|
|
257
|
+
log "$phase $status — commit $sha, pushed $pushed"
|
|
258
|
+
if [[ $rc -ne 0 ]]; then report; commit_phase "report" >/dev/null; exit "$rc"; fi
|
|
259
|
+
done
|
|
260
|
+
[[ $DRY -eq 1 ]] && exit 0
|
|
261
|
+
report; read -r sha pushed <<<"$(commit_phase "build report")"
|
|
262
|
+
log "UAT-ready — final report commit $sha, pushed $pushed"
|
|
263
|
+
mv -f "$STATE" "$DIR/.tfcore/.session/develop-done.json" # develop mode ends with the run
|
|
264
|
+
exit 0
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
bash .tfcore/utils/tf-devguide-list.sh <App> [--phase N] [--update]
|
|
5
5
|
|
|
6
|
-
Reads appKind and appPhase from .tfcore/core-config.yaml and prints:
|
|
6
|
+
Reads appKind (or metrics.project_type) and appPhase from .tfcore/core-config.yaml and prints:
|
|
7
7
|
app every routed page found in the code (route, file), matched to the phase's
|
|
8
8
|
UIDesign screens and to the roles in the UsageGuide's Test users table;
|
|
9
9
|
UIDesign screens with no code (a defect), routes with no UIDesign screen
|
|
@@ -151,7 +151,11 @@ def main(argv):
|
|
|
151
151
|
app = argv[1]
|
|
152
152
|
update = "--update" in argv
|
|
153
153
|
phase = int(argv[argv.index("--phase") + 1]) if "--phase" in argv and argv.index("--phase") + 1 < len(argv) else int(cfg("appPhase", "1"))
|
|
154
|
-
kind = cfg("appKind", "
|
|
154
|
+
kind = cfg("appKind", "").lower()
|
|
155
|
+
if not kind: # metrics.project_type: library says it when appKind is not set (TrBlazeUI TF-005)
|
|
156
|
+
pt = re.search(r"(?m)^\s+project_type:\s*(\S+)", read(os.path.join(".tfcore", "core-config.yaml"))) \
|
|
157
|
+
if os.path.isfile(os.path.join(".tfcore", "core-config.yaml")) else None
|
|
158
|
+
kind = pt.group(1).strip("'\"").lower() if pt else "app"
|
|
155
159
|
if "--kind" in argv and argv.index("--kind") + 1 < len(argv):
|
|
156
160
|
kind = argv[argv.index("--kind") + 1].lower() # override for a project whose config lacks it
|
|
157
161
|
sub = "app"
|
|
@@ -70,7 +70,9 @@ 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"}
|
|
@@ -96,9 +98,10 @@ NAMES_SCREEN = re.compile(r"\b(on|opens?|from|in)\b", re.I)
|
|
|
96
98
|
|
|
97
99
|
|
|
98
100
|
def acceptance_words(line: str) -> int:
|
|
99
|
-
"""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)."""
|
|
100
103
|
t = re.sub(r"^\s*[-*]\s*\*?Acceptance:?\*?:?\s*", "", line)
|
|
101
|
-
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]
|
|
102
105
|
return len(re.findall(r"\S+", t))
|
|
103
106
|
|
|
104
107
|
|
|
@@ -331,7 +334,7 @@ def read_core_config(root: str) -> dict:
|
|
|
331
334
|
if os.path.exists(p):
|
|
332
335
|
with open(p, encoding="utf-8") as fh:
|
|
333
336
|
for line in fh:
|
|
334
|
-
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)
|
|
335
338
|
if m and m.group(2) not in ("null", "~", "''", '""'):
|
|
336
339
|
out[m.group(1)] = m.group(2).strip("'\"")
|
|
337
340
|
return out
|
|
@@ -356,6 +359,24 @@ def foreign_prefix(path: str):
|
|
|
356
359
|
return app if app and apps and app.lower() not in apps else None
|
|
357
360
|
|
|
358
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
|
+
|
|
359
380
|
def phase_file(app: str, phase: int, suffix: str) -> str:
|
|
360
381
|
return f"{app}-{suffix}.md" if phase <= 1 else f"{app}-P{phase}-{suffix}.md"
|
|
361
382
|
|
|
@@ -450,6 +471,10 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
450
471
|
rep.fail(rel, f'a phase-{phase} file needs the header row "Phase | {phase} of <m>"')
|
|
451
472
|
elif os.path.exists(phases_doc):
|
|
452
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 ""
|
|
453
478
|
|
|
454
479
|
# 2. sections: strangers, order, presence
|
|
455
480
|
present = [(norm_heading(h), h, txt) for h, txt in split_sections(clean, 2) if h is not None]
|
|
@@ -502,7 +527,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
502
527
|
target, mx = target + extra * schema.per_entry[0], mx + extra * schema.per_entry[1]
|
|
503
528
|
why = f", with {extra} entries past the cap of {SCREEN_CAP[size]}" if extra else ""
|
|
504
529
|
if total > mx:
|
|
505
|
-
|
|
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}")
|
|
506
531
|
elif total > target:
|
|
507
532
|
rep.warn(rel, f"{total:,} words; the {SIZE_LONG[size]} target is {target:,} (maximum {mx:,}{why})")
|
|
508
533
|
for key, h, txt in present:
|
|
@@ -564,7 +589,7 @@ def check_document(path: str, rep: Report, cli_size=None, root=None):
|
|
|
564
589
|
# 5. document rules
|
|
565
590
|
ctx = dict(doc=doc, body=body, clean=clean, nocomment=nocomment, present=present, header=header,
|
|
566
591
|
size=size, kind=kind, root=root, path=path, rel=rel, entry_names=entry_names,
|
|
567
|
-
app=app, phase=phase)
|
|
592
|
+
app=app, phase=phase, size_finding=size_finding, kept_why=kept_why)
|
|
568
593
|
for rule in schema.rules:
|
|
569
594
|
if not rule.startswith("entry-"):
|
|
570
595
|
check_doc_rule(rule, ctx, rep)
|
|
@@ -653,7 +678,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
653
678
|
if dupes:
|
|
654
679
|
rep.fail(rel, "duplicate requirement ids: " + ", ".join(f"BRD-{d}" for d in sorted(dupes, key=int)))
|
|
655
680
|
if len(seen) > REQ_CAP[size]:
|
|
656
|
-
|
|
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']}")
|
|
657
682
|
c["brd_ids"] = seen
|
|
658
683
|
req = section_text(present, "Requirements") or ""
|
|
659
684
|
cur = None
|
|
@@ -986,7 +1011,7 @@ def check_doc_rule(rule, c, rep):
|
|
|
986
1011
|
rep.fail(rel, f"phase {n} BRD range runs backwards ({x} to {y})")
|
|
987
1012
|
st = r[ist].strip("`* ").lower()
|
|
988
1013
|
if st not in PHASE_STATUS:
|
|
989
|
-
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')
|
|
990
1015
|
rows_out.append(dict(n=n, name=r[iname].strip("`* "), screens=screens, lo=lo, hi=hi, ranges=ranges, status=st))
|
|
991
1016
|
nums = [p["n"] for p in rows_out]
|
|
992
1017
|
if nums != list(range(1, len(nums) + 1)):
|
|
@@ -1033,10 +1058,12 @@ def check_checklist(c, rep):
|
|
|
1033
1058
|
row_status[rid] = status.strip("`* ").lower()
|
|
1034
1059
|
if not re.fullmatch(r"REQ-(UI|FN|RAG|NFR)-\d{3}", rid):
|
|
1035
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"
|
|
1036
1062
|
if status.strip("`* ").lower() not in STATUS_VALUES:
|
|
1037
|
-
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 ""))
|
|
1038
1065
|
if pct.strip("`* ").rstrip("%").strip() not in ("0", "25", "50", "75", "100"):
|
|
1039
|
-
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)
|
|
1040
1067
|
n = len(re.findall(r"\S+", remarks))
|
|
1041
1068
|
if n > 60:
|
|
1042
1069
|
rep.fail(rel, f"{rid} Remarks is {n} words; at most 60, current state only (history lives in the telemetry streams)")
|
|
@@ -1046,6 +1073,10 @@ def check_checklist(c, rep):
|
|
|
1046
1073
|
said = re.sub(r"`[^`]*(?:`|$)", " ", remarks)
|
|
1047
1074
|
if NOT_PRESENT.search(said) and not NAMES_PATH.search(remarks):
|
|
1048
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
|
|
1049
1080
|
m = re.search(r"\(#([^)]+)\)", details)
|
|
1050
1081
|
if not m:
|
|
1051
1082
|
rep.fail(rel, f"{rid} Details cell has no link to its detail entry")
|
|
@@ -1054,7 +1085,7 @@ def check_checklist(c, rep):
|
|
|
1054
1085
|
if not ids:
|
|
1055
1086
|
rep.fail(rel, "the Requirements Status table has no REQ- rows")
|
|
1056
1087
|
if len(ids) > REQ_CAP[size]:
|
|
1057
|
-
|
|
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']}")
|
|
1058
1089
|
dupes = sorted({i for i in ids if ids.count(i) > 1})
|
|
1059
1090
|
if dupes:
|
|
1060
1091
|
rep.fail(rel, "duplicate rows: " + ", ".join(dupes))
|
|
@@ -1076,6 +1107,9 @@ def check_checklist(c, rep):
|
|
|
1076
1107
|
start = pos + own.end()
|
|
1077
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)
|
|
1078
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
|
|
1079
1113
|
acc = [l for l in entry.splitlines() if ACCEPT_LINE.search(l)]
|
|
1080
1114
|
if len(acc) != 1:
|
|
1081
1115
|
rep.fail(rel, f"{rid} must have exactly one acceptance line; found {len(acc)}")
|
|
@@ -1100,7 +1134,10 @@ def check_checklist(c, rep):
|
|
|
1100
1134
|
brd_refs.update(refs)
|
|
1101
1135
|
if rid.startswith("REQ-UI"):
|
|
1102
1136
|
links = links_to(entry, "mockups")
|
|
1103
|
-
|
|
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"):
|
|
1104
1141
|
rep.fail(rel, f"{rid} is a UI row without a mockup link")
|
|
1105
1142
|
for l in sorted(links):
|
|
1106
1143
|
if not resolve(root, path, l):
|
|
@@ -1275,14 +1312,18 @@ NAV_ITEM = re.compile(r"""<(div|li|span|button)\b[^>]*\bclass\s*=\s*["'][^"']*(?
|
|
|
1275
1312
|
|
|
1276
1313
|
|
|
1277
1314
|
def _mock_target(mock_dir: str, t: str):
|
|
1278
|
-
"""Resolve a link the way a browser opening docs/mockups/<file> would. Returns the
|
|
1279
|
-
|
|
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."""
|
|
1280
1319
|
if re.match(r"^(https?:|mailto:|tel:|javascript:|data:)", t, re.I) or t.startswith("/"):
|
|
1281
1320
|
return None
|
|
1321
|
+
t = re.split(r"[?#]", t, maxsplit=1)[0]
|
|
1322
|
+
base = os.path.normpath(mock_dir)
|
|
1282
1323
|
p = os.path.normpath(os.path.join(mock_dir, t))
|
|
1283
|
-
if
|
|
1324
|
+
if not (p == base or p.startswith(base + os.sep)) or not os.path.isfile(p):
|
|
1284
1325
|
return None
|
|
1285
|
-
return os.path.
|
|
1326
|
+
return os.path.relpath(p, base).replace(os.sep, "/")
|
|
1286
1327
|
|
|
1287
1328
|
|
|
1288
1329
|
def check_mockups(root: str, rep: Report):
|
|
@@ -1315,7 +1356,8 @@ def check_mockups(root: str, rep: Report):
|
|
|
1315
1356
|
continue
|
|
1316
1357
|
name = _mock_target(mock_dir, t)
|
|
1317
1358
|
if name is None:
|
|
1318
|
-
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/")
|
|
1319
1361
|
rep.fail(rel, f'links to "{t}", which does not open from docs/mockups/ ({hint})')
|
|
1320
1362
|
else:
|
|
1321
1363
|
targets.append(name)
|
|
@@ -1420,6 +1462,7 @@ def main(argv=None):
|
|
|
1420
1462
|
if a.app:
|
|
1421
1463
|
root = root or os.getcwd()
|
|
1422
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
|
|
1423
1466
|
if not files:
|
|
1424
1467
|
print(f"tf-doc-check: no documents for app {a.app} under {root}", file=sys.stderr)
|
|
1425
1468
|
return 2
|
|
@@ -1452,9 +1495,16 @@ def main(argv=None):
|
|
|
1452
1495
|
base = json.load(fh) or {}
|
|
1453
1496
|
except Exception:
|
|
1454
1497
|
base = {}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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)
|
|
1458
1508
|
os.makedirs(os.path.dirname(base_path), exist_ok=True)
|
|
1459
1509
|
with open(base_path, "w", encoding="utf-8") as fh:
|
|
1460
1510
|
json.dump(base, fh, indent=1)
|
|
@@ -1468,11 +1518,23 @@ def main(argv=None):
|
|
|
1468
1518
|
with open(base_path, encoding="utf-8") as fh:
|
|
1469
1519
|
base = json.load(fh) or {}
|
|
1470
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
|
|
1471
1530
|
for l in rep.lines:
|
|
1472
1531
|
if l.startswith("FAIL "):
|
|
1473
1532
|
head, msg = l.split(": ", 1)
|
|
1474
1533
|
r = head[len("FAIL "):]
|
|
1475
|
-
|
|
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:
|
|
1476
1538
|
relined.append("OLD " + l[5:])
|
|
1477
1539
|
rep.fails -= 1
|
|
1478
1540
|
old += 1
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
# docs/Lekhak-UsageGuide.md and fails when the REQ-NFR-042 paragraph is gone. *amend-docs removed that
|
|
8
8
|
# paragraph, ran no test, closed green, and only CI noticed. This script looks for test source files
|
|
9
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
|
|
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:
|
|
11
12
|
# NONE no test reads <docs>; nothing to run exit 0
|
|
12
13
|
# PASS <n> test file(s) read <docs> (<files>); the unit tests pass — <build line> exit 0
|
|
13
14
|
# FAIL <n> test file(s) read <docs> (<files>); the unit tests fail — <build line> exit 1
|
|
@@ -20,7 +21,7 @@ DOCS=(); TARGET=""
|
|
|
20
21
|
while [[ $# -gt 0 ]]; do
|
|
21
22
|
case "$1" in
|
|
22
23
|
--target) TARGET="${2:-}"; shift 2 ;;
|
|
23
|
-
-h|--help) sed -n '2,
|
|
24
|
+
-h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
24
25
|
*) DOCS+=("$1"); shift ;;
|
|
25
26
|
esac
|
|
26
27
|
done
|
|
@@ -71,7 +72,43 @@ if [[ -z "$TARGET" && -z "$ROOTSLN" ]]; then
|
|
|
71
72
|
fi
|
|
72
73
|
exit 2
|
|
73
74
|
fi
|
|
74
|
-
|
|
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
|
|
75
112
|
case "$LINE" in
|
|
76
113
|
PASS*) echo "PASS $N test file(s) read ${DOCS[*]} ($LIST); the unit tests pass — $LINE"; exit 0 ;;
|
|
77
114
|
FAIL*) echo "FAIL $N test file(s) read ${DOCS[*]} ($LIST); the unit tests fail — $LINE"; exit 1 ;;
|