@techierathore/techieflow 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/TechieFlow/agents/analyst.md +97 -0
- package/.claude/commands/TechieFlow/agents/architect.md +84 -0
- package/.claude/commands/TechieFlow/agents/flow-master.md +217 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +72 -0
- package/.claude/commands/TechieFlow/tasks/_metrics-emit-gate.md +138 -0
- package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +98 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +78 -0
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +77 -0
- package/.claude/commands/TechieFlow/tasks/advanced-elicitation.md +117 -0
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +149 -0
- package/.claude/commands/TechieFlow/tasks/author-brd.md +79 -0
- package/.claude/commands/TechieFlow/tasks/build-phase.md +231 -0
- package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +278 -0
- package/.claude/commands/TechieFlow/tasks/create-doc.md +101 -0
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +507 -0
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +224 -0
- package/.claude/commands/TechieFlow/tasks/devguide.md +224 -0
- package/.claude/commands/TechieFlow/tasks/document-project.md +343 -0
- package/.claude/commands/TechieFlow/tasks/execute-checklist.md +86 -0
- package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +136 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +134 -0
- package/.claude/commands/TechieFlow/tasks/generate-html.md +115 -0
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +126 -0
- package/.claude/commands/TechieFlow/tasks/index-docs.md +173 -0
- package/.claude/commands/TechieFlow/tasks/kb-mode-interaction.md +75 -0
- package/.claude/commands/TechieFlow/tasks/log-miss.md +160 -0
- package/.claude/commands/TechieFlow/tasks/metrics-report.md +143 -0
- package/.claude/commands/TechieFlow/tasks/mockups.md +122 -0
- package/.claude/commands/TechieFlow/tasks/productguide.md +105 -0
- package/.claude/commands/TechieFlow/tasks/refresh-status.md +135 -0
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +117 -0
- package/.claude/commands/TechieFlow/tasks/shard-doc.md +185 -0
- package/.claude/commands/TechieFlow/tasks/split-brd.md +131 -0
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +157 -0
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +579 -0
- package/.claude/commands/generate-html.md +124 -0
- package/.codex/config.toml +46 -0
- package/.codex/hooks.json +63 -0
- package/.codex/rules/techieflow.rules +8 -0
- package/.opencode/command/generate-html.md +129 -0
- package/.opencode/plugin/techieflow.js +350 -0
- package/.tfcore/TOKEN-GUIDE.md +59 -0
- package/.tfcore/agents/analyst.md +97 -0
- package/.tfcore/agents/architect.md +84 -0
- package/.tfcore/agents/flow-master.md +217 -0
- package/.tfcore/agents/verifier.md +72 -0
- package/.tfcore/checklists/architect-checklist.md +438 -0
- package/.tfcore/core-config.yaml +47 -0
- package/.tfcore/data/brainstorming-techniques.md +36 -0
- package/.tfcore/data/elicitation-methods.md +154 -0
- package/.tfcore/data/techieflow-kb.md +807 -0
- package/.tfcore/data/technical-preferences.md +3 -0
- package/.tfcore/enhanced-ide-development-workflow.md +248 -0
- package/.tfcore/hooks/block-git.sh +301 -0
- package/.tfcore/hooks/codex-adapter.py +248 -0
- package/.tfcore/hooks/guard-artifacts.sh +101 -0
- package/.tfcore/hooks/guard-status-html.sh +102 -0
- package/.tfcore/hooks/guard-status.sh +124 -0
- package/.tfcore/hooks/guard-verify.sh +108 -0
- package/.tfcore/hooks/metrics-session.sh +129 -0
- package/.tfcore/hooks/session-pointer.sh +52 -0
- package/.tfcore/hooks/sweep-artifacts.sh +198 -0
- package/.tfcore/install-manifest.yaml +231 -0
- package/.tfcore/routing.yaml +105 -0
- package/.tfcore/tasks/_metrics-emit-gate.md +138 -0
- package/.tfcore/tasks/_smoke-test-policy.md +98 -0
- package/.tfcore/tasks/_status-update-gate.md +78 -0
- package/.tfcore/tasks/_yolo-mode.md +77 -0
- package/.tfcore/tasks/advanced-elicitation.md +117 -0
- package/.tfcore/tasks/amend-docs.md +149 -0
- package/.tfcore/tasks/author-brd.md +79 -0
- package/.tfcore/tasks/build-phase.md +231 -0
- package/.tfcore/tasks/create-deep-research-prompt.md +278 -0
- package/.tfcore/tasks/create-doc.md +101 -0
- package/.tfcore/tasks/day1-brownfield.md +507 -0
- package/.tfcore/tasks/day1-greenfield.md +224 -0
- package/.tfcore/tasks/devguide.md +224 -0
- package/.tfcore/tasks/document-project.md +343 -0
- package/.tfcore/tasks/execute-checklist.md +86 -0
- package/.tfcore/tasks/facilitate-brainstorming-session.md +136 -0
- package/.tfcore/tasks/fix-issues.md +134 -0
- package/.tfcore/tasks/generate-html.md +115 -0
- package/.tfcore/tasks/handoff-phase.md +126 -0
- package/.tfcore/tasks/index-docs.md +173 -0
- package/.tfcore/tasks/kb-mode-interaction.md +75 -0
- package/.tfcore/tasks/log-miss.md +160 -0
- package/.tfcore/tasks/metrics-report.md +143 -0
- package/.tfcore/tasks/mockups.md +122 -0
- package/.tfcore/tasks/productguide.md +105 -0
- package/.tfcore/tasks/refresh-status.md +135 -0
- package/.tfcore/tasks/render-workflow-docs.md +117 -0
- package/.tfcore/tasks/shard-doc.md +185 -0
- package/.tfcore/tasks/split-brd.md +131 -0
- package/.tfcore/tasks/triage-issues.md +157 -0
- package/.tfcore/tasks/verify-phase.md +579 -0
- package/.tfcore/telemetry/SCHEMA.md +678 -0
- package/.tfcore/telemetry/install-metrics.sh +390 -0
- package/.tfcore/telemetry/pre-commit +94 -0
- package/.tfcore/telemetry/tf-metrics.sh +1415 -0
- package/.tfcore/templates/architecture-tmpl.yaml +650 -0
- package/.tfcore/templates/brainstorming-output-tmpl.yaml +156 -0
- package/.tfcore/templates/brownfield-architecture-tmpl.yaml +476 -0
- package/.tfcore/templates/competitor-analysis-tmpl.yaml +306 -0
- package/.tfcore/templates/front-end-architecture-tmpl.yaml +218 -0
- package/.tfcore/templates/fullstack-architecture-tmpl.yaml +823 -0
- package/.tfcore/templates/market-research-tmpl.yaml +252 -0
- package/.tfcore/templates/project-brief-tmpl.yaml +221 -0
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +61 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +127 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +204 -0
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +90 -0
- package/.tfcore/templates/v4custom/app-claude-md-tmpl.md +33 -0
- package/.tfcore/templates/v4custom/app-devguide-tmpl.md +167 -0
- package/.tfcore/templates/v4custom/app-editorconfig-tmpl.editorconfig +44 -0
- package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +31 -0
- package/.tfcore/templates/v4custom/app-productguide-tmpl.md +54 -0
- package/.tfcore/templates/v4custom/app-project-status-tmpl.md +98 -0
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +48 -0
- package/.tfcore/templates/v4custom/app-usageguide-tmpl.md +67 -0
- package/.tfcore/templates/v4custom/build-invocation-ladder.md +190 -0
- package/.tfcore/templates/v4custom/html-render-shell.md +502 -0
- package/.tfcore/templates/v4custom/metrics-report-template.md +273 -0
- package/.tfcore/templates/v4custom/opencode-operating-contract.md +56 -0
- package/.tfcore/user-guide.md +633 -0
- package/.tfcore/utils/techieflow-doc-template.md +325 -0
- package/.tfcore/utils/tf-assets.sh +310 -0
- package/.tfcore/utils/tf-codex-bind.py +196 -0
- package/.tfcore/utils/tf-codex-telemetry.py +60 -0
- package/.tfcore/utils/tf-emit.sh +1075 -0
- package/.tfcore/utils/tf-gitignore-audit.sh +281 -0
- package/.tfcore/utils/tf-goal.sh +421 -0
- package/.tfcore/utils/tf-harness.sh +160 -0
- package/.tfcore/utils/tf-mockup-parity.mjs +555 -0
- package/.tfcore/utils/tf-mockup-parity.sh +65 -0
- package/.tfcore/utils/tf-opencode-audit.py +126 -0
- package/.tfcore/utils/tf-perf.sh +241 -0
- package/.tfcore/utils/tf-render-html.py +715 -0
- package/.tfcore/utils/tf-render-html.sh +28 -0
- package/.tfcore/utils/tf-routing-bind.sh +216 -0
- package/.tfcore/utils/tf-routing.sh +247 -0
- package/.tfcore/utils/tf-yolo.sh +119 -0
- package/.tfcore/working-in-the-brownfield.md +606 -0
- package/LICENSE +201 -0
- package/README.md +1212 -0
- package/WORKFLOW.html +1986 -0
- package/docs/TechieFlow-Installation.md +299 -0
- package/opencode.jsonc +929 -0
- package/package.json +60 -0
- package/scripts/install.mjs +992 -0
- package/scripts/test-install.mjs +505 -0
- package/scripts/validate.mjs +155 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audit an app's ROOT opencode.jsonc against the framework template.
|
|
3
|
+
|
|
4
|
+
Prints ONE verdict line on stdout:
|
|
5
|
+
current byte-identical to the template — nothing to do
|
|
6
|
+
refresh carries no project-only content — safe to replace wholesale
|
|
7
|
+
project|<k1,k2,...> carries project-only keys — must be preserved, keys listed
|
|
8
|
+
unknown|<reason> could not be parsed — caller falls back to preserving
|
|
9
|
+
|
|
10
|
+
Also prints, after the verdict, zero or more DIAG lines the caller echoes:
|
|
11
|
+
DIAG|dead-ref|<ref> a {file:...} reference that does not resolve in the app
|
|
12
|
+
DIAG|bare-bash-allow|<where> a genuine agent-level "bash": "allow" string value
|
|
13
|
+
|
|
14
|
+
"Project-only" means a key the framework template does not define, excluding the
|
|
15
|
+
framework's own `techieflow:` command namespace (retired framework commands are
|
|
16
|
+
framework litter, not project content, and must NOT block a refresh).
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def strip_jsonc(text):
|
|
25
|
+
"""Remove // line comments and trailing commas. Quote-aware: a // inside a
|
|
26
|
+
string literal (e.g. a URL) is left alone."""
|
|
27
|
+
out, in_str, esc, i = [], False, False, 0
|
|
28
|
+
while i < len(text):
|
|
29
|
+
c = text[i]
|
|
30
|
+
if in_str:
|
|
31
|
+
out.append(c)
|
|
32
|
+
if esc:
|
|
33
|
+
esc = False
|
|
34
|
+
elif c == "\\":
|
|
35
|
+
esc = True
|
|
36
|
+
elif c == '"':
|
|
37
|
+
in_str = False
|
|
38
|
+
i += 1
|
|
39
|
+
continue
|
|
40
|
+
if c == '"':
|
|
41
|
+
in_str = True
|
|
42
|
+
out.append(c)
|
|
43
|
+
i += 1
|
|
44
|
+
continue
|
|
45
|
+
if c == "/" and i + 1 < len(text) and text[i + 1] == "/":
|
|
46
|
+
while i < len(text) and text[i] != "\n":
|
|
47
|
+
i += 1
|
|
48
|
+
continue
|
|
49
|
+
if c == "/" and i + 1 < len(text) and text[i + 1] == "*":
|
|
50
|
+
j = text.find("*/", i + 2)
|
|
51
|
+
i = len(text) if j < 0 else j + 2
|
|
52
|
+
continue
|
|
53
|
+
out.append(c)
|
|
54
|
+
i += 1
|
|
55
|
+
s = "".join(out)
|
|
56
|
+
return re.sub(r",(\s*[}\]])", r"\1", s)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load(path):
|
|
60
|
+
raw = open(path, encoding="utf-8").read()
|
|
61
|
+
return json.loads(strip_jsonc(raw)), raw
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main():
|
|
65
|
+
tmpl_path, app_path = sys.argv[1], sys.argv[2]
|
|
66
|
+
app_dir = os.path.dirname(os.path.abspath(app_path)) or "."
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
tmpl, tmpl_raw = load(tmpl_path)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
print("unknown|template unreadable: %s" % e)
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
app, app_raw = load(app_path)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print("unknown|app config does not parse: %s" % e)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
diags = []
|
|
80
|
+
|
|
81
|
+
# --- genuine bare agent-level "bash": "allow" -------------------------
|
|
82
|
+
# Checked on the PARSED tree, never on raw text: the template's own comment
|
|
83
|
+
# contains the literal string `"bash": "allow"`, so a grep over the raw file
|
|
84
|
+
# matches its own documentation and fires on every correct config.
|
|
85
|
+
for name, block in (app.get("agent") or {}).items():
|
|
86
|
+
if not isinstance(block, dict):
|
|
87
|
+
continue
|
|
88
|
+
perm = block.get("permission")
|
|
89
|
+
if isinstance(perm, dict) and perm.get("bash") == "allow":
|
|
90
|
+
diags.append("DIAG|bare-bash-allow|agent.%s" % name)
|
|
91
|
+
root_perm = app.get("permission")
|
|
92
|
+
if isinstance(root_perm, dict) and root_perm.get("bash") == "allow":
|
|
93
|
+
diags.append("DIAG|bare-bash-allow|permission")
|
|
94
|
+
|
|
95
|
+
# --- dead {file:...} refs (hard-fails OpenCode's whole config load) ----
|
|
96
|
+
for ref in sorted(set(re.findall(r"\{file:([^}]*)\}", app_raw))):
|
|
97
|
+
rel = ref[2:] if ref.startswith("./") else ref
|
|
98
|
+
if not os.path.isfile(os.path.join(app_dir, rel)):
|
|
99
|
+
diags.append("DIAG|dead-ref|%s" % ref)
|
|
100
|
+
|
|
101
|
+
# --- project-only content ---------------------------------------------
|
|
102
|
+
project = []
|
|
103
|
+
for k in app:
|
|
104
|
+
if k not in tmpl:
|
|
105
|
+
project.append(k)
|
|
106
|
+
for k in (app.get("agent") or {}):
|
|
107
|
+
if k not in (tmpl.get("agent") or {}):
|
|
108
|
+
project.append("agent.%s" % k)
|
|
109
|
+
for k in (app.get("command") or {}):
|
|
110
|
+
if k in (tmpl.get("command") or {}):
|
|
111
|
+
continue
|
|
112
|
+
if k.startswith("techieflow:"):
|
|
113
|
+
continue # retired FRAMEWORK command — litter, not project content
|
|
114
|
+
project.append("command.%s" % k)
|
|
115
|
+
|
|
116
|
+
if app_raw == tmpl_raw:
|
|
117
|
+
print("current")
|
|
118
|
+
elif project:
|
|
119
|
+
print("project|%s" % ",".join(sorted(project)))
|
|
120
|
+
else:
|
|
121
|
+
print("refresh")
|
|
122
|
+
for d in diags:
|
|
123
|
+
print(d)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
main()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# TechieFlow — performance measurement harness. Feeds verify-phase §4c (the perf gate).
|
|
3
|
+
#
|
|
4
|
+
# bash .tfcore/utils/tf-perf.sh --base http://localhost:5099 \
|
|
5
|
+
# --paths "/,/posts,/admin/dashboard" --levels 1,50 --build-config Release
|
|
6
|
+
#
|
|
7
|
+
# WHAT IT IS: a deterministic TTFB / full-load sampler over a declared path set at
|
|
8
|
+
# declared concurrency levels. It prints ONE JSON object and measures nothing else.
|
|
9
|
+
#
|
|
10
|
+
# WHAT IT IS NOT: a verdict. It never decides pass/fail, never reads a budget, never
|
|
11
|
+
# touches a checklist, and never emits telemetry. verify-phase §4c compares its numbers
|
|
12
|
+
# against the budget declared in the REQ; this script only produces the numbers. That
|
|
13
|
+
# separation is deliberate — a measurement tool that also judges is a tool you cannot
|
|
14
|
+
# audit when a number looks wrong.
|
|
15
|
+
#
|
|
16
|
+
# MEASUREMENT DEFINITIONS (stated because a perf number is meaningless without them):
|
|
17
|
+
# ttfb_ms wall time from issuing the request to the FIRST BYTE of the response body
|
|
18
|
+
# (headers already received). This is what a page-load budget is really about.
|
|
19
|
+
# load_ms wall time until the response body is fully read.
|
|
20
|
+
# Both EXCLUDE connection setup of the warm-up requests, which are discarded: ASP.NET
|
|
21
|
+
# JITs on first hit and a cold first request is not what a user experiences.
|
|
22
|
+
#
|
|
23
|
+
# HONESTY RULES BAKED IN:
|
|
24
|
+
# - p95 is the headline, never the mean. One 4-second stall matters; an average hides it.
|
|
25
|
+
# - Every sample count is reported. A p95 over 4 samples is noise and says so (`"weak":true`).
|
|
26
|
+
# - build_config is stamped, never guessed. A Debug-build number is not evidence and
|
|
27
|
+
# verify-phase §4c refuses to fail a REQ on one.
|
|
28
|
+
# - machine fingerprint is printed for the HUMAN so two runs are never silently compared
|
|
29
|
+
# across hosts. It is NOT telemetry and must never be emitted to gates.jsonl.
|
|
30
|
+
#
|
|
31
|
+
# Exit codes: 0 measured · 2 app unreachable (nothing measured) · 3 bad arguments
|
|
32
|
+
# · 4 every request redirected (auth wall — nothing measured).
|
|
33
|
+
# Requires python3 only (the framework already depends on it for the guard hooks).
|
|
34
|
+
|
|
35
|
+
set -uo pipefail
|
|
36
|
+
|
|
37
|
+
BASE=""; PATHS="/"; LEVELS="1"; REQS=5; WARMUP=3; TIMEOUT=30; BUILD="unknown"; LABEL=""; OUT=""
|
|
38
|
+
HDRS="" # newline-separated "K: V" pairs (--header, repeatable; --cookie is sugar)
|
|
39
|
+
while [[ $# -gt 0 ]]; do
|
|
40
|
+
case "$1" in
|
|
41
|
+
--base) BASE="${2:-}"; shift 2 ;;
|
|
42
|
+
--paths) PATHS="${2:-}"; shift 2 ;;
|
|
43
|
+
--levels) LEVELS="${2:-}"; shift 2 ;;
|
|
44
|
+
--requests) REQS="${2:-}"; shift 2 ;;
|
|
45
|
+
--warmup) WARMUP="${2:-}"; shift 2 ;;
|
|
46
|
+
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
|
|
47
|
+
--build-config) BUILD="${2:-}"; shift 2 ;;
|
|
48
|
+
--label) LABEL="${2:-}"; shift 2 ;;
|
|
49
|
+
--json-out) OUT="${2:-}"; shift 2 ;;
|
|
50
|
+
--header) HDRS="${HDRS}${2:-}"$'\n'; shift 2 ;;
|
|
51
|
+
--cookie) HDRS="${HDRS}Cookie: ${2:-}"$'\n'; shift 2 ;;
|
|
52
|
+
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
|
|
53
|
+
*) echo "unknown argument: $1" >&2; exit 3 ;;
|
|
54
|
+
esac
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
[[ -n "$BASE" ]] || { printf '%s\n' \
|
|
58
|
+
"usage: tf-perf.sh --base URL [--paths /,/a] [--levels 1,50] [--build-config Release]" \
|
|
59
|
+
" [--cookie 'k=v; k2=v2'] [--header 'Authorization: Bearer ...']" >&2; exit 3; }
|
|
60
|
+
command -v python3 >/dev/null 2>&1 || { echo '{"status":"no-python3"}'; exit 3; }
|
|
61
|
+
case "$BUILD" in Release|Debug|unknown) ;; *) echo "--build-config must be Release|Debug|unknown" >&2; exit 3 ;; esac
|
|
62
|
+
|
|
63
|
+
TF_BASE="$BASE" TF_PATHS="$PATHS" TF_LEVELS="$LEVELS" TF_REQS="$REQS" TF_WARMUP="$WARMUP" \
|
|
64
|
+
TF_TIMEOUT="$TIMEOUT" TF_BUILD="$BUILD" TF_LABEL="$LABEL" TF_OUT="$OUT" TF_HEADERS="$HDRS" python3 - <<'PY'
|
|
65
|
+
import http.client, json, os, platform, ssl, statistics, sys, threading, time
|
|
66
|
+
from urllib.parse import urlparse
|
|
67
|
+
|
|
68
|
+
BASE = os.environ["TF_BASE"].rstrip("/")
|
|
69
|
+
PATHS = [p.strip() for p in os.environ["TF_PATHS"].split(",") if p.strip()]
|
|
70
|
+
LEVELS = [int(x) for x in os.environ["TF_LEVELS"].split(",") if x.strip()]
|
|
71
|
+
REQS = int(os.environ["TF_REQS"]); WARMUP = int(os.environ["TF_WARMUP"])
|
|
72
|
+
TIMEOUT = float(os.environ["TF_TIMEOUT"]); BUILD = os.environ["TF_BUILD"]
|
|
73
|
+
LABEL = os.environ["TF_LABEL"] or None; OUT = os.environ["TF_OUT"]
|
|
74
|
+
|
|
75
|
+
# Extra request headers (--header / --cookie). Without these the harness can only
|
|
76
|
+
# ever measure the anonymous surface: on a login-gated app every route answers 302
|
|
77
|
+
# to /login and the "latency" is the speed of being turned away (TfLens TF-002).
|
|
78
|
+
EXTRA = {}
|
|
79
|
+
for _h in os.environ.get("TF_HEADERS", "").split("\n"):
|
|
80
|
+
if ":" in _h:
|
|
81
|
+
_k, _v = _h.split(":", 1)
|
|
82
|
+
EXTRA[_k.strip()] = _v.strip()
|
|
83
|
+
|
|
84
|
+
u = urlparse(BASE)
|
|
85
|
+
HOST, PORT, HTTPS = u.hostname, u.port or (443 if u.scheme == "https" else 80), u.scheme == "https"
|
|
86
|
+
CTX = ssl._create_unverified_context() if HTTPS else None # dev certs: verified elsewhere, not here
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def hit(path):
|
|
90
|
+
"""One request. Returns (ttfb_ms, load_ms, status) or (None, None, error-string)."""
|
|
91
|
+
conn = None
|
|
92
|
+
try:
|
|
93
|
+
conn = (http.client.HTTPSConnection(HOST, PORT, timeout=TIMEOUT, context=CTX) if HTTPS
|
|
94
|
+
else http.client.HTTPConnection(HOST, PORT, timeout=TIMEOUT))
|
|
95
|
+
t0 = time.perf_counter()
|
|
96
|
+
_hdrs = {"Accept": "text/html,*/*", "Connection": "close"}
|
|
97
|
+
_hdrs.update(EXTRA)
|
|
98
|
+
conn.request("GET", path, headers=_hdrs)
|
|
99
|
+
resp = conn.getresponse()
|
|
100
|
+
resp.read(1) # <- first body byte defines TTFB
|
|
101
|
+
ttfb = (time.perf_counter() - t0) * 1000.0
|
|
102
|
+
resp.read()
|
|
103
|
+
return ttfb, (time.perf_counter() - t0) * 1000.0, resp.status
|
|
104
|
+
except Exception as e:
|
|
105
|
+
return None, None, type(e).__name__
|
|
106
|
+
finally:
|
|
107
|
+
if conn:
|
|
108
|
+
try: conn.close()
|
|
109
|
+
except Exception: pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- reachability, before anything is measured -------------------------------
|
|
113
|
+
probe = None
|
|
114
|
+
for _ in range(3):
|
|
115
|
+
probe = hit(PATHS[0])
|
|
116
|
+
if probe[0] is not None:
|
|
117
|
+
break
|
|
118
|
+
time.sleep(1)
|
|
119
|
+
if probe[0] is None:
|
|
120
|
+
print(json.dumps({"status": "unreachable", "base": BASE, "path": PATHS[0], "error": probe[2]}))
|
|
121
|
+
sys.exit(2)
|
|
122
|
+
|
|
123
|
+
# --- warm-up: discarded on purpose (JIT / first-request compilation) ---------
|
|
124
|
+
for p in PATHS:
|
|
125
|
+
for _ in range(WARMUP):
|
|
126
|
+
hit(p)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def pct(xs, q):
|
|
130
|
+
if not xs: return None
|
|
131
|
+
xs = sorted(xs)
|
|
132
|
+
if len(xs) == 1: return round(xs[0], 1)
|
|
133
|
+
k = (len(xs) - 1) * q
|
|
134
|
+
lo, hi = int(k), min(int(k) + 1, len(xs) - 1)
|
|
135
|
+
return round(xs[lo] + (xs[hi] - xs[lo]) * (k - lo), 1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def summarize(xs):
|
|
139
|
+
return {"p50": pct(xs, 0.50), "p95": pct(xs, 0.95), "max": round(max(xs), 1) if xs else None,
|
|
140
|
+
"mean": round(statistics.fmean(xs), 1) if xs else None}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
results = []
|
|
144
|
+
for conc in LEVELS:
|
|
145
|
+
samples, lock = {p: {"ttfb": [], "load": []} for p in PATHS}, threading.Lock()
|
|
146
|
+
errors, non200, redirects = [], [], []
|
|
147
|
+
|
|
148
|
+
def worker():
|
|
149
|
+
for _ in range(REQS):
|
|
150
|
+
for p in PATHS:
|
|
151
|
+
ttfb, load, st = hit(p)
|
|
152
|
+
with lock:
|
|
153
|
+
if ttfb is None:
|
|
154
|
+
errors.append(st)
|
|
155
|
+
else:
|
|
156
|
+
if st != 200: non200.append((p, st))
|
|
157
|
+
if 300 <= st < 400: redirects.append((p, st))
|
|
158
|
+
samples[p]["ttfb"].append(ttfb); samples[p]["load"].append(load)
|
|
159
|
+
|
|
160
|
+
threads = [threading.Thread(target=worker, daemon=True) for _ in range(conc)]
|
|
161
|
+
t0 = time.perf_counter()
|
|
162
|
+
for t in threads: t.start()
|
|
163
|
+
for t in threads: t.join()
|
|
164
|
+
elapsed = time.perf_counter() - t0
|
|
165
|
+
|
|
166
|
+
all_ttfb = [v for p in PATHS for v in samples[p]["ttfb"]]
|
|
167
|
+
all_load = [v for p in PATHS for v in samples[p]["load"]]
|
|
168
|
+
results.append({
|
|
169
|
+
"concurrency": conc,
|
|
170
|
+
"samples": len(all_ttfb),
|
|
171
|
+
# A p95 needs samples behind it. Below 20 the number is indicative, not evidence,
|
|
172
|
+
# and verify-phase §4c must not fail a REQ on it.
|
|
173
|
+
"weak": len(all_ttfb) < 20,
|
|
174
|
+
"errors": len(errors),
|
|
175
|
+
# Rate, not just count — verify-phase §4c branches on it. A handful of errors
|
|
176
|
+
# contaminates a latency sample (do not grade); a large fraction IS the result
|
|
177
|
+
# (the app did not serve the declared concurrency). Only the rate separates them.
|
|
178
|
+
"attempted": len(all_ttfb) + len(errors),
|
|
179
|
+
"error_rate": (round(len(errors) / (len(all_ttfb) + len(errors)), 3)
|
|
180
|
+
if (len(all_ttfb) + len(errors)) else None),
|
|
181
|
+
"error_kinds": sorted(set(errors))[:5],
|
|
182
|
+
"non_200": sorted({f"{p}:{s}" for p, s in non200})[:5],
|
|
183
|
+
# A redirect is not a page. Surfaced as a first-class rate so neither
|
|
184
|
+
# verify-phase §4c nor a human has to infer it from the non_200 list.
|
|
185
|
+
"redirects": len(redirects),
|
|
186
|
+
"redirect_rate": (round(len(redirects) / len(all_ttfb), 3) if all_ttfb else None),
|
|
187
|
+
"wall_s": round(elapsed, 2),
|
|
188
|
+
"ttfb_ms": summarize(all_ttfb),
|
|
189
|
+
"load_ms": summarize(all_load),
|
|
190
|
+
"per_path": [{"path": p,
|
|
191
|
+
"samples": len(samples[p]["ttfb"]),
|
|
192
|
+
"ttfb_p95": pct(samples[p]["ttfb"], 0.95),
|
|
193
|
+
"load_p95": pct(samples[p]["load"], 0.95)} for p in PATHS],
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
# --- an all-redirect run is a REFUSAL, never a latency figure ----------------
|
|
197
|
+
# On a login-gated app every route answers 302 to /login in a few ms, and the old
|
|
198
|
+
# harness reported that as a page-load p95 — "the speed of being turned away at
|
|
199
|
+
# the door" (TfLens TF-002). The redirects were visible in non_200, but nothing
|
|
200
|
+
# marked the number meaningless, so the figure read as real. Refuse instead:
|
|
201
|
+
# a gate that quietly reports a wrong number is worse than one that declines.
|
|
202
|
+
_sampled = sum(l["samples"] for l in results)
|
|
203
|
+
_redir = sum(l["redirects"] for l in results)
|
|
204
|
+
if _sampled and _redir == _sampled:
|
|
205
|
+
print(json.dumps({
|
|
206
|
+
"status": "redirected",
|
|
207
|
+
"schema": "tf-perf/1",
|
|
208
|
+
"base": BASE,
|
|
209
|
+
"paths": PATHS,
|
|
210
|
+
"redirects": _redir,
|
|
211
|
+
"codes": sorted({c for l in results for c in l["non_200"]})[:8],
|
|
212
|
+
"hint": ("every request was redirected — nothing was measured. These paths "
|
|
213
|
+
"need a session: re-run with --cookie 'name=value' or "
|
|
214
|
+
"--header 'Authorization: Bearer …'. Grade the REQ "
|
|
215
|
+
"PERF-UNMEASURED until then (verify-phase §4c)."),
|
|
216
|
+
}, indent=2))
|
|
217
|
+
sys.exit(4)
|
|
218
|
+
|
|
219
|
+
out = {
|
|
220
|
+
"status": "ok",
|
|
221
|
+
"schema": "tf-perf/1",
|
|
222
|
+
"base": BASE,
|
|
223
|
+
"label": LABEL,
|
|
224
|
+
"build_config": BUILD,
|
|
225
|
+
"paths": PATHS,
|
|
226
|
+
"warmup_discarded": WARMUP,
|
|
227
|
+
"started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
228
|
+
# For the HUMAN reading the report: never compare two runs whose fingerprints differ.
|
|
229
|
+
# NOT telemetry — must never reach gates.jsonl (SCHEMA.md §3.3, constraint 7).
|
|
230
|
+
"machine": {"platform": platform.system(), "release": platform.release(),
|
|
231
|
+
"cpus": os.cpu_count(), "python": platform.python_version()},
|
|
232
|
+
"levels": results,
|
|
233
|
+
}
|
|
234
|
+
text = json.dumps(out, indent=2)
|
|
235
|
+
if OUT:
|
|
236
|
+
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)
|
|
237
|
+
open(OUT, "w", encoding="utf-8").write(text + "\n")
|
|
238
|
+
print(text)
|
|
239
|
+
else:
|
|
240
|
+
print(text)
|
|
241
|
+
PY
|