@techierathore/techieflow 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude/commands/TechieFlow/agents/analyst.md +5 -8
  2. package/.claude/commands/TechieFlow/agents/architect.md +1 -10
  3. package/.claude/commands/TechieFlow/agents/flow-master.md +1 -0
  4. package/.claude/commands/TechieFlow/agents/verifier.md +1 -0
  5. package/.claude/commands/TechieFlow/tasks/_owner-language.md +27 -0
  6. package/.claude/commands/TechieFlow/tasks/_smoke-test-policy.md +1 -1
  7. package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
  8. package/.claude/commands/TechieFlow/tasks/amend-docs.md +6 -5
  9. package/.claude/commands/TechieFlow/tasks/build-phase.md +1 -1
  10. package/.claude/commands/TechieFlow/tasks/create-deep-research-prompt.md +4 -0
  11. package/.claude/commands/TechieFlow/tasks/create-doc.md +4 -0
  12. package/.claude/commands/TechieFlow/tasks/create-project-brief.md +22 -0
  13. package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
  14. package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +3 -3
  15. package/.claude/commands/TechieFlow/tasks/facilitate-brainstorming-session.md +4 -0
  16. package/.claude/commands/TechieFlow/tasks/fix-issues.md +2 -2
  17. package/.claude/commands/TechieFlow/tasks/generate-html.md +7 -3
  18. package/.claude/commands/TechieFlow/tasks/handoff-phase.md +20 -18
  19. package/.claude/commands/TechieFlow/tasks/metrics-report.md +2 -0
  20. package/.claude/commands/TechieFlow/tasks/mockups.md +1 -1
  21. package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
  22. package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
  23. package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
  24. package/.claude/commands/TechieFlow/tasks/verify-phase.md +1 -1
  25. package/.opencode/plugin/techieflow.js +181 -9
  26. package/.tfcore/agents/analyst.md +5 -8
  27. package/.tfcore/agents/architect.md +1 -10
  28. package/.tfcore/agents/flow-master.md +1 -0
  29. package/.tfcore/agents/verifier.md +1 -0
  30. package/.tfcore/hooks/block-git.sh +4 -2
  31. package/.tfcore/hooks/guard-build.sh +5 -1
  32. package/.tfcore/hooks/guard-db.sh +6 -4
  33. package/.tfcore/hooks/guard-metrics.sh +13 -1
  34. package/.tfcore/hooks/guard-status-html.sh +86 -2
  35. package/.tfcore/hooks/guard-verify-deps.sh +124 -0
  36. package/.tfcore/hooks/guard-verify.sh +8 -3
  37. package/.tfcore/hooks/sweep-artifacts.sh +46 -0
  38. package/.tfcore/routing.yaml +61 -0
  39. package/.tfcore/standards/owner-words.txt +23 -0
  40. package/.tfcore/tasks/_owner-language.md +27 -0
  41. package/.tfcore/tasks/_smoke-test-policy.md +1 -1
  42. package/.tfcore/tasks/_status-update-gate.md +4 -3
  43. package/.tfcore/tasks/amend-docs.md +6 -5
  44. package/.tfcore/tasks/build-phase.md +1 -1
  45. package/.tfcore/tasks/create-deep-research-prompt.md +4 -0
  46. package/.tfcore/tasks/create-doc.md +4 -0
  47. package/.tfcore/tasks/create-project-brief.md +22 -0
  48. package/.tfcore/tasks/day1-brownfield.md +3 -3
  49. package/.tfcore/tasks/day1-greenfield.md +3 -3
  50. package/.tfcore/tasks/facilitate-brainstorming-session.md +4 -0
  51. package/.tfcore/tasks/fix-issues.md +2 -2
  52. package/.tfcore/tasks/generate-html.md +7 -3
  53. package/.tfcore/tasks/handoff-phase.md +20 -18
  54. package/.tfcore/tasks/metrics-report.md +2 -0
  55. package/.tfcore/tasks/mockups.md +1 -1
  56. package/.tfcore/tasks/productguide.md +2 -2
  57. package/.tfcore/tasks/render-workflow-docs.md +1 -1
  58. package/.tfcore/tasks/triage-issues.md +2 -2
  59. package/.tfcore/tasks/verify-phase.md +1 -1
  60. package/.tfcore/telemetry/SCHEMA.md +61 -1
  61. package/.tfcore/telemetry/install-metrics.sh +3 -2
  62. package/.tfcore/telemetry/tf-metrics.sh +435 -21
  63. package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
  64. package/.tfcore/templates/v4custom/app-brd-tmpl.md +5 -3
  65. package/.tfcore/templates/v4custom/app-brief-tmpl.md +65 -0
  66. package/.tfcore/templates/v4custom/app-decision-request-tmpl.md +85 -0
  67. package/.tfcore/templates/v4custom/app-library-feedback-tmpl.md +86 -31
  68. package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
  69. package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
  70. package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
  71. package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
  72. package/.tfcore/templates/v4custom/html-render-shell.md +4 -2
  73. package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
  74. package/.tfcore/utils/__pycache__/tf-checklist-edit.cpython-312.pyc +0 -0
  75. package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
  76. package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
  77. package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
  78. package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
  79. package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
  80. package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
  81. package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
  82. package/.tfcore/utils/__pycache__/tf-perf-grade.cpython-312.pyc +0 -0
  83. package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
  84. package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
  85. package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
  86. package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
  87. package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
  88. package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
  89. package/.tfcore/utils/tf-assets-browser.mjs +96 -0
  90. package/.tfcore/utils/tf-assets.sh +97 -12
  91. package/.tfcore/utils/tf-brd-status.py +14 -5
  92. package/.tfcore/utils/tf-build-list.py +99 -11
  93. package/.tfcore/utils/tf-build-list.sh +5 -3
  94. package/.tfcore/utils/tf-build.sh +88 -15
  95. package/.tfcore/utils/tf-ci-repro.py +685 -0
  96. package/.tfcore/utils/tf-ci-repro.sh +24 -0
  97. package/.tfcore/utils/tf-devguide-list.py +47 -22
  98. package/.tfcore/utils/tf-doc-check.py +242 -10
  99. package/.tfcore/utils/tf-doc-tests.sh +79 -0
  100. package/.tfcore/utils/tf-emit.sh +413 -19
  101. package/.tfcore/utils/tf-feedback.sh +13 -0
  102. package/.tfcore/utils/tf-fix-close.sh +113 -31
  103. package/.tfcore/utils/tf-gitignore-audit.sh +50 -3
  104. package/.tfcore/utils/tf-goal.sh +105 -18
  105. package/.tfcore/utils/tf-harness.sh +9 -4
  106. package/.tfcore/utils/tf-lock.sh +29 -0
  107. package/.tfcore/utils/tf-log-miss.py +34 -8
  108. package/.tfcore/utils/tf-login.mjs +167 -0
  109. package/.tfcore/utils/tf-misses-md.py +24 -21
  110. package/.tfcore/utils/tf-misses-md.sh +5 -4
  111. package/.tfcore/utils/tf-mockup-parity.mjs +427 -63
  112. package/.tfcore/utils/tf-mockup-parity.sh +14 -2
  113. package/.tfcore/utils/tf-model-pick.sh +475 -0
  114. package/.tfcore/utils/tf-owner-text.py +233 -0
  115. package/.tfcore/utils/tf-owner-text.sh +14 -0
  116. package/.tfcore/utils/tf-phase.sh +33 -1
  117. package/.tfcore/utils/tf-portable.sh +230 -0
  118. package/.tfcore/utils/tf-render-html.py +80 -2
  119. package/.tfcore/utils/tf-routing-bind.sh +87 -1
  120. package/.tfcore/utils/tf-routing.sh +7 -0
  121. package/.tfcore/utils/tf-selfcheck.sh +356 -0
  122. package/.tfcore/utils/tf-split-brd.py +17 -2
  123. package/.tfcore/utils/tf-status-evidence.sh +11 -9
  124. package/.tfcore/utils/tf-status-facts.py +133 -44
  125. package/.tfcore/utils/tf-triage.py +62 -16
  126. package/.tfcore/utils/tf-triage.sh +2 -0
  127. package/.tfcore/utils/tf-verify-boot.sh +263 -43
  128. package/.tfcore/utils/tf-verify-emit.sh +20 -0
  129. package/.tfcore/utils/tf-verify-env.sh +33 -4
  130. package/.tfcore/utils/tf-verify-list.py +75 -8
  131. package/.tfcore/utils/tf-verify-screens.mjs +254 -43
  132. package/.tfcore/utils/tf-verify-screens.sh +18 -4
  133. package/.tfcore/utils/tf-verify-tests.sh +272 -28
  134. package/.tfcore/utils/tf-verify-verdict.py +59 -11
  135. package/.tfcore/utils/tf_defect.py +27 -0
  136. package/.tfcore/utils/tf_feedback.py +209 -0
  137. package/.tfcore/utils/tf_roadmap.py +35 -0
  138. package/README.md +19 -5
  139. package/docs/TechieFlow-Installation.md +5 -3
  140. package/opencode.jsonc +24 -0
  141. package/package.json +1 -1
  142. package/scripts/install.mjs +4 -0
  143. package/.tfcore/templates/architecture-tmpl.yaml +0 -650
  144. package/.tfcore/templates/brownfield-architecture-tmpl.yaml +0 -476
  145. package/.tfcore/templates/competitor-analysis-tmpl.yaml +0 -306
  146. package/.tfcore/templates/front-end-architecture-tmpl.yaml +0 -218
  147. package/.tfcore/templates/fullstack-architecture-tmpl.yaml +0 -823
  148. package/.tfcore/templates/market-research-tmpl.yaml +0 -252
  149. package/.tfcore/templates/project-brief-tmpl.yaml +0 -221
  150. package/.tfcore/utils/techieflow-doc-template.md +0 -325
@@ -0,0 +1,685 @@
1
+ #!/usr/bin/env python3
2
+ """tf-ci-repro.py — run a CI workflow's steps here the way a fresh runner does (Lekhak TF-019, 2026-09-30).
3
+
4
+ Called by tf-ci-repro.sh; see that file for the usage and the verdict lines.
5
+
6
+ A developer machine hides CI failures in two places: its package caches and its build output.
7
+ Lekhak's CI failed with NETSDK1112 (the win-x64 runtime pack was never downloaded) while the same
8
+ commands passed on the developer's machine, because the pack was already in ~/.nuget/packages.
9
+ triage-and-fix reported the failure fixed; the owner's next CI run failed again. This script:
10
+ * copies the repository without anything its .gitignore files ignore (no bin/, obj/,
11
+ node_modules/ ...), as a fresh checkout has none of them, to a folder outside the repository;
12
+ * points every package cache it knows at an empty folder (NuGet, npm, yarn, pnpm, pip, Go,
13
+ Gradle, Maven);
14
+ * runs the job's `run:` steps in order, in the shell the runner would use, and stops at the
15
+ first failing one, as the runner does.
16
+ `uses:` steps are not run: the copy stands in for the checkout, the tools installed here for the
17
+ setup actions, and a cache action is left out on purpose. A step that needs a secret is skipped:
18
+ this machine's own credentials stand in. A step that only installs tools on the machine
19
+ (workloads, apt, choco, brew, winget) is skipped unless --run-setup.
20
+ """
21
+ import json, os, re, shutil, subprocess, sys, time
22
+ from datetime import datetime, timezone
23
+
24
+ CACHES = [ # env var, folder name, whose cache
25
+ ("NUGET_PACKAGES", "nuget", "NuGet"), ("NUGET_HTTP_CACHE_PATH", "nuget-http", None),
26
+ ("npm_config_cache", "npm", "npm"), ("YARN_CACHE_FOLDER", "yarn", "yarn"),
27
+ ("npm_config_store_dir", "pnpm", "pnpm"), ("PIP_CACHE_DIR", "pip", "pip"),
28
+ ("GOMODCACHE", "gomod", "Go"), ("GRADLE_USER_HOME", "gradle", "Gradle"),
29
+ ]
30
+ SETUP_RX = re.compile(r"^\s*(sudo\s+)?(dotnet\s+workload\s+(install|update)|apt(-get)?\s+(update|install)|"
31
+ r"choco\s+install|brew\s+install|winget\s+install|npm\s+(i|install)\s+(-g|--global))\b", re.I)
32
+
33
+
34
+ def say(*a):
35
+ print(*a, flush=True)
36
+
37
+
38
+ # ---- a workflow reader that needs no PyYAML -----------------------------------------------------
39
+ # A stock Mac's python3 has no PyYAML, and CI's stock-Mac job failed on it (2026-09-30). PyYAML is used
40
+ # when it is installed; otherwise this reader takes the part of YAML a workflow file uses: block
41
+ # mappings and sequences, | and > block scalars, quoted and plain scalars, and one-line [ ] and { }.
42
+ # Anchors, tags and multi-line flow collections are not read.
43
+ def _strip_comment(s):
44
+ q = None
45
+ for i, c in enumerate(s):
46
+ if q:
47
+ if c == q:
48
+ q = None
49
+ elif c in "'\"" and (i == 0 or s[i - 1] in " [{,:"):
50
+ q = c
51
+ elif c == "#" and (i == 0 or s[i - 1] in " \t"):
52
+ return s[:i].rstrip()
53
+ return s.rstrip()
54
+
55
+
56
+ def _split_flow(s):
57
+ parts, depth, q, cur = [], 0, None, ""
58
+ for c in s:
59
+ if q:
60
+ q = None if c == q else q
61
+ elif c in "'\"":
62
+ q = c
63
+ elif c in "[{":
64
+ depth += 1
65
+ elif c in "]}":
66
+ depth -= 1
67
+ elif c == "," and depth == 0:
68
+ parts.append(cur.strip()); cur = ""; continue
69
+ cur += c
70
+ if cur.strip():
71
+ parts.append(cur.strip())
72
+ return parts
73
+
74
+
75
+ def _scalar(s):
76
+ s = s.strip()
77
+ if not s:
78
+ return None
79
+ if s[0] == "'" and s.endswith("'") and len(s) > 1:
80
+ return s[1:-1].replace("''", "'")
81
+ if s[0] == '"' and s.endswith('"') and len(s) > 1:
82
+ try:
83
+ return json.loads(s)
84
+ except ValueError:
85
+ return s[1:-1]
86
+ if s[0] == "[" and s.endswith("]"):
87
+ return [_scalar(p) for p in _split_flow(s[1:-1])]
88
+ if s[0] == "{" and s.endswith("}"):
89
+ out = {}
90
+ for p in _split_flow(s[1:-1]):
91
+ k, _, v = p.partition(":")
92
+ out[_scalar(k)] = _scalar(v)
93
+ return out
94
+ s = _strip_comment(s)
95
+ low = s.lower()
96
+ if low in ("true", "false"):
97
+ return low == "true"
98
+ if low in ("null", "~"):
99
+ return None
100
+ if re.match(r"^-?\d+$", s):
101
+ return int(s)
102
+ if re.match(r"^-?\d+\.\d+$", s):
103
+ return float(s)
104
+ return s
105
+
106
+
107
+ _KEY = re.compile(r"""^('(?:[^']|'')*'|"(?:[^"\\]|\\.)*"|[^\s'"#\-\[{][^:#]*?|-[^\s:][^:#]*?)\s*:(?:\s+|$)(.*)$""")
108
+
109
+
110
+ def mini_yaml(text):
111
+ lines = text.replace("\t", " ").splitlines()
112
+ ends_nl = text.endswith("\n") # a block scalar that ends the file keeps a newline only if the file has one
113
+
114
+ def meaningful(i):
115
+ while i < len(lines) and (not lines[i].strip() or lines[i].strip().startswith("#") or lines[i].strip() == "---"):
116
+ i += 1
117
+ return i
118
+
119
+ def ind(i):
120
+ return len(lines[i]) - len(lines[i].lstrip(" "))
121
+
122
+ def block_scalar(head, i, parent):
123
+ style, chomp = head[0], ("-" if "-" in head else "+" if "+" in head else "")
124
+ body, width = [], None
125
+ while i < len(lines) and (not lines[i].strip() or ind(i) > parent):
126
+ if lines[i].strip() and width is None:
127
+ width = ind(i)
128
+ body.append(lines[i][width:] if width is not None and len(lines[i]) > width else "")
129
+ i += 1
130
+ while body and chomp != "+" and body[-1] == "":
131
+ body.pop()
132
+ if style == "|":
133
+ txt = "\n".join(body)
134
+ else:
135
+ txt, prev_blank = "", False
136
+ for ln in body:
137
+ if not ln:
138
+ txt += "\n"; prev_blank = True
139
+ else:
140
+ txt += (ln if not txt or txt.endswith("\n") else " " + ln); prev_blank = False
141
+ last_line_open = i >= len(lines) and not ends_nl and not (chomp != "+" and not lines[-1].strip())
142
+ return txt + ("" if chomp == "-" or not txt or last_line_open else "\n"), i
143
+
144
+ def value(rest, i, n):
145
+ rest = _strip_comment(rest) if not rest.startswith(("'", '"')) else rest
146
+ if rest[:1] in ("|", ">"):
147
+ return block_scalar(rest, i, n)
148
+ if rest:
149
+ return _scalar(rest), i
150
+ j = meaningful(i)
151
+ if j < len(lines) and (ind(j) > n or (ind(j) == n and lines[j].lstrip().startswith("- ") )):
152
+ return node(j)
153
+ return None, i
154
+
155
+ def node(i):
156
+ i = meaningful(i)
157
+ if i >= len(lines):
158
+ return None, i
159
+ n = ind(i)
160
+ if lines[i].lstrip().startswith("-") and lines[i].strip() in ("-",) or lines[i].lstrip().startswith("- "):
161
+ seq = []
162
+ while i < len(lines) and ind(i) == n and (lines[i].strip() == "-" or lines[i].lstrip().startswith("- ")):
163
+ content = lines[i].strip()[1:].lstrip()
164
+ if not content:
165
+ v, i = node(i + 1)
166
+ elif _KEY.match(content) and not content.startswith(("'", '"', "[", "{")) or re.match(r"""^('[^']*'|"[^"]*")\s*:(\s|$)""", content):
167
+ lines[i] = " " * (len(lines[i]) - len(content)) + content
168
+ v, i = node(i)
169
+ else:
170
+ v, i = value(content, i + 1, n)
171
+ seq.append(v)
172
+ i = meaningful(i)
173
+ return seq, i
174
+ out = {}
175
+ while i < len(lines) and ind(i) == n and not lines[i].lstrip().startswith("- "):
176
+ m = _KEY.match(lines[i].strip())
177
+ if not m:
178
+ raise ValueError("line %d: cannot read %r" % (i + 1, lines[i].strip()))
179
+ out[_scalar(m.group(1))], i = value(m.group(2), i + 1, n)
180
+ i = meaningful(i)
181
+ return out, i
182
+
183
+ v, _ = node(0)
184
+ return v
185
+
186
+
187
+ def load_workflow(path):
188
+ text = open(path, encoding="utf-8").read()
189
+ if not os.environ.get("TF_CI_REPRO_NO_PYYAML"):
190
+ try:
191
+ import yaml
192
+ return yaml.safe_load(text)
193
+ except ImportError:
194
+ pass
195
+ return mini_yaml(text)
196
+
197
+
198
+ # ---- a clean copy: what a fresh checkout holds ------------------------------------------------
199
+ # Every .gitignore in the tree is read the way git reads it (last matching rule wins, "!" re-includes,
200
+ # a trailing "/" means a folder, a "/" elsewhere anchors the rule to that .gitignore's folder, "**"
201
+ # crosses folders). An ignored folder is not entered. Done in Python so the copy needs no rsync
202
+ # `--filter` support, which a stock Mac's rsync may lack.
203
+ def _glob_rx(pat):
204
+ rx, i = "", 0
205
+ while i < len(pat):
206
+ c = pat[i]
207
+ if pat.startswith("**/", i):
208
+ rx += "(?:.*/)?"; i += 3; continue
209
+ if pat.startswith("/**", i) and i + 3 == len(pat):
210
+ rx += "/.*"; i += 3; continue
211
+ if pat.startswith("**", i):
212
+ rx += ".*"; i += 2; continue
213
+ if c == "*":
214
+ rx += "[^/]*"
215
+ elif c == "?":
216
+ rx += "[^/]"
217
+ elif c == "\\" and i + 1 < len(pat):
218
+ i += 1; rx += re.escape(pat[i])
219
+ elif c == "[":
220
+ j = pat.find("]", i + 1)
221
+ if j < 0:
222
+ rx += "\\["
223
+ else:
224
+ rx += "[" + pat[i + 1:j].replace("!", "^", 1) + "]"; i = j
225
+ else:
226
+ rx += re.escape(c)
227
+ i += 1
228
+ return rx
229
+
230
+
231
+ def _read_ignore(path):
232
+ rules = []
233
+ for ln in read_text(path).splitlines():
234
+ ln = ln.rstrip("\r")
235
+ if not ln.strip() or ln.startswith("#"):
236
+ continue
237
+ ln = re.sub(r"(?<!\\)\s+$", "", ln)
238
+ neg = ln.startswith("!")
239
+ if neg:
240
+ ln = ln[1:]
241
+ dir_only = ln.endswith("/")
242
+ ln = ln.rstrip("/")
243
+ anchored = "/" in ln
244
+ ln = ln.lstrip("/")
245
+ rules.append((re.compile("^" + _glob_rx(ln) + "$"), neg, dir_only, anchored))
246
+ return rules
247
+
248
+
249
+ def clean_copy(src, dst):
250
+ stack = [] # (folder relative to src, rules)
251
+
252
+ def ignored(rel, is_dir):
253
+ hit = False
254
+ for base, rules in stack:
255
+ if base and not rel.startswith(base + "/"):
256
+ continue
257
+ sub = rel[len(base) + 1:] if base else rel
258
+ name = sub.rsplit("/", 1)[-1]
259
+ for rx, neg, dir_only, anchored in rules:
260
+ if dir_only and not is_dir:
261
+ continue
262
+ if rx.match(sub if anchored else name):
263
+ hit = not neg
264
+ return hit
265
+
266
+ n = 0
267
+ for root, dirs, files in os.walk(src):
268
+ rel_root = os.path.relpath(root, src).replace(os.sep, "/")
269
+ rel_root = "" if rel_root == "." else rel_root
270
+ while stack and stack[-1][0] and not (rel_root == stack[-1][0] or rel_root.startswith(stack[-1][0] + "/")):
271
+ stack.pop()
272
+ if ".gitignore" in files:
273
+ stack.append((rel_root, _read_ignore(os.path.join(root, ".gitignore"))))
274
+ keep = []
275
+ for d in sorted(dirs):
276
+ rel = (rel_root + "/" if rel_root else "") + d
277
+ if d == ".git" or rel == "tests/.artifacts" or ignored(rel, True):
278
+ continue
279
+ keep.append(d)
280
+ os.makedirs(os.path.join(dst, rel), exist_ok=True)
281
+ dirs[:] = keep
282
+ for f in files:
283
+ rel = (rel_root + "/" if rel_root else "") + f
284
+ if ignored(rel, False):
285
+ continue
286
+ s, t = os.path.join(root, f), os.path.join(dst, rel)
287
+ os.makedirs(os.path.dirname(t), exist_ok=True)
288
+ if os.path.islink(s):
289
+ os.symlink(os.readlink(s), t)
290
+ else:
291
+ shutil.copy2(s, t)
292
+ n += 1
293
+ return n
294
+
295
+
296
+ def is_wsl():
297
+ try:
298
+ return "microsoft" in open("/proc/version").read().lower()
299
+ except OSError:
300
+ return False
301
+
302
+
303
+ HOST = "macos" if sys.platform == "darwin" else ("windows" if os.name == "nt" else ("wsl" if is_wsl() else "linux"))
304
+
305
+
306
+ def winpath(p):
307
+ return subprocess.run(["wslpath", "-w", p], capture_output=True, text=True).stdout.strip() if HOST == "wsl" else p
308
+
309
+
310
+ def read_text(p):
311
+ try:
312
+ b = open(p, "rb").read()
313
+ except OSError:
314
+ return ""
315
+ if b[:2] in (b"\xff\xfe", b"\xfe\xff"):
316
+ return b.decode("utf-16")
317
+ return b.decode("utf-8-sig", errors="replace")
318
+
319
+
320
+ def parse_kv_file(p):
321
+ """GITHUB_OUTPUT / GITHUB_ENV: name=value lines and name<<DELIM heredocs."""
322
+ out, lines, i = {}, read_text(p).replace("\r\n", "\n").split("\n"), 0
323
+ while i < len(lines):
324
+ ln = lines[i]; i += 1
325
+ m = re.match(r"^([^=<\s]+)<<(\S+)$", ln)
326
+ if m:
327
+ buf = []
328
+ while i < len(lines) and lines[i] != m.group(2):
329
+ buf.append(lines[i]); i += 1
330
+ i += 1; out[m.group(1)] = "\n".join(buf)
331
+ elif "=" in ln:
332
+ k, v = ln.split("=", 1); out[k.strip()] = v
333
+ return out
334
+
335
+
336
+ # ---- expressions ------------------------------------------------------------------------------
337
+ class NeedsSecret(Exception):
338
+ pass
339
+
340
+
341
+ class Unknown(Exception):
342
+ pass
343
+
344
+
345
+ def evaluate(expr, ctx):
346
+ """The small part of the ${{ }} language a run step or its `if:` usually needs."""
347
+ toks = re.findall(r"'(?:[^']|'')*'|&&|\|\||==|!=|!|\(|\)|[A-Za-z_][\w\-]*(?:\.[\w\-\*]+)*(?:\(\))?|-?\d+(?:\.\d+)?", expr)
348
+ if "".join(toks).replace(" ", "") != re.sub(r"\s+", "", expr):
349
+ raise Unknown(expr)
350
+ pos = [0]
351
+
352
+ def peek():
353
+ return toks[pos[0]] if pos[0] < len(toks) else None
354
+
355
+ def take():
356
+ t = toks[pos[0]]; pos[0] += 1; return t
357
+
358
+ def atom():
359
+ t = take()
360
+ if t == "(":
361
+ v = orx()
362
+ if take() != ")":
363
+ raise Unknown(expr)
364
+ return v
365
+ if t == "!":
366
+ return not truthy(atom())
367
+ if t.startswith("'"):
368
+ return t[1:-1].replace("''", "'")
369
+ if re.match(r"^-?\d", t):
370
+ return float(t)
371
+ if t in ("true", "false"):
372
+ return t == "true"
373
+ if t == "null":
374
+ return None
375
+ if t in ("always()", "success()"):
376
+ return True
377
+ if t in ("failure()", "cancelled()"):
378
+ return False
379
+ return lookup(t, ctx)
380
+
381
+ def cmp():
382
+ v = atom()
383
+ while peek() in ("==", "!="):
384
+ op, r = take(), atom()
385
+ eq = str(v).lower() == str(r).lower() if not (isinstance(v, float) and isinstance(r, float)) else v == r
386
+ v = eq if op == "==" else not eq
387
+ return v
388
+
389
+ def andx():
390
+ v = cmp()
391
+ while peek() == "&&":
392
+ take(); r = cmp(); v = r if truthy(v) else v
393
+ return v
394
+
395
+ def orx():
396
+ v = andx()
397
+ while peek() == "||":
398
+ take(); r = andx(); v = v if truthy(v) else r
399
+ return v
400
+
401
+ v = orx()
402
+ if pos[0] != len(toks):
403
+ raise Unknown(expr)
404
+ return v
405
+
406
+
407
+ def truthy(v):
408
+ return v not in (None, False, "", 0, 0.0)
409
+
410
+
411
+ def lookup(name, ctx):
412
+ parts = name.split(".")
413
+ if parts[0] == "secrets":
414
+ raise NeedsSecret(name)
415
+ if parts[0] == "steps" and len(parts) == 4 and parts[2] == "outputs":
416
+ return ctx["outputs"].get(parts[1], {}).get(parts[3], "")
417
+ if parts[0] == "steps" and len(parts) == 3 and parts[2] in ("outcome", "conclusion"):
418
+ return ctx["outcome"].get(parts[1], "")
419
+ if parts[0] == "env" and len(parts) == 2:
420
+ return ctx["env"].get(parts[1], "")
421
+ if parts[0] == "matrix" and len(parts) == 2 and parts[1] in ctx["matrix"]:
422
+ return ctx["matrix"][parts[1]]
423
+ fixed = {"runner.os": ctx["runner_os"], "runner.temp": ctx["temp_shell"], "github.workspace": ctx["work_shell"],
424
+ "github.event_name": "workflow_dispatch", "github.run_number": "1", "github.run_id": "0"}
425
+ if name in fixed:
426
+ return fixed[name]
427
+ raise Unknown(name)
428
+
429
+
430
+ def expand(text, ctx):
431
+ def one(m):
432
+ v = evaluate(m.group(1), ctx)
433
+ if isinstance(v, bool):
434
+ return "true" if v else "false"
435
+ if isinstance(v, float) and v.is_integer():
436
+ return str(int(v))
437
+ return "" if v is None else str(v)
438
+ return re.sub(r"\$\{\{\s*(.*?)\s*\}\}", one, str(text), flags=re.S)
439
+
440
+
441
+ def secrets_in(obj):
442
+ return sorted(set(re.findall(r"secrets\.([A-Za-z_]\w*)", json.dumps(obj, default=str))))
443
+
444
+
445
+ # ---- main -------------------------------------------------------------------------------------
446
+ def pick_workflow(arg):
447
+ if arg:
448
+ return arg if os.path.isfile(arg) else None
449
+ found = sorted(os.path.join(".github/workflows", f) for f in os.listdir(".github/workflows")
450
+ if f.endswith((".yml", ".yaml"))) if os.path.isdir(".github/workflows") else []
451
+ if len(found) == 1:
452
+ return found[0]
453
+ _notrun("no workflow under .github/workflows; name one" if not found else
454
+ "%d workflows here (%s); name one" % (len(found), ", ".join(found)))
455
+
456
+
457
+ def _notrun(msg):
458
+ say("NOT-RUN " + msg)
459
+ sys.exit(2)
460
+
461
+
462
+ def runner_os(runs_on, matrix):
463
+ s = json.dumps(runs_on).lower()
464
+ if "matrix." in s:
465
+ for v in matrix.values():
466
+ s += " " + str(v).lower()
467
+ return "Windows" if "windows" in s else ("macOS" if "macos" in s else "Linux")
468
+
469
+
470
+ def work_root():
471
+ base = os.environ.get("TF_CI_REPRO_DIR")
472
+ if not base and HOST == "wsl":
473
+ t = subprocess.run(["cmd.exe", "/c", "echo %TEMP%"], capture_output=True, text=True, cwd="/mnt/c").stdout.strip()
474
+ base = subprocess.run(["wslpath", "-u", t], capture_output=True, text=True).stdout.strip() if t else ""
475
+ if not base:
476
+ base = os.environ.get("TMPDIR") or "/tmp"
477
+ return os.path.join(base, "tf-ci-repro", "%s-%d" % (os.path.basename(os.getcwd()), os.getpid()))
478
+
479
+
480
+ def main():
481
+ a = sys.argv[1:]
482
+ opt = {"workflow": None, "job": None, "list": False, "keep": False, "setup": False}
483
+ while a:
484
+ x = a.pop(0)
485
+ if x == "--job": opt["job"] = a.pop(0) if a else None
486
+ elif x == "--list": opt["list"] = True
487
+ elif x == "--keep": opt["keep"] = True
488
+ elif x == "--run-setup": opt["setup"] = True
489
+ elif x.startswith("-"): _notrun("unknown option %s" % x)
490
+ else: opt["workflow"] = x
491
+ wf_path = pick_workflow(opt["workflow"])
492
+ if not wf_path:
493
+ _notrun("no workflow file %s" % opt["workflow"])
494
+ try:
495
+ wf = load_workflow(wf_path) or {}
496
+ except ValueError as e:
497
+ _notrun("could not read %s without PyYAML (%s); python3 -m pip install pyyaml" % (wf_path, e))
498
+ jobs = wf.get("jobs") or {}
499
+ if not jobs:
500
+ _notrun("%s has no jobs" % wf_path)
501
+ if opt["job"] and opt["job"] not in jobs:
502
+ _notrun("no job %s in %s (jobs: %s)" % (opt["job"], wf_path, ", ".join(jobs)))
503
+ if not opt["job"] and len(jobs) > 1:
504
+ _notrun("%s has %d jobs (%s); name one with --job" % (wf_path, len(jobs), ", ".join(jobs)))
505
+ jid = opt["job"] or next(iter(jobs))
506
+ job = jobs[jid] or {}
507
+ if "uses" in job:
508
+ _notrun("job %s calls another workflow (%s); run that one" % (jid, job["uses"]))
509
+ mx = ((job.get("strategy") or {}).get("matrix") or {})
510
+ matrix = {k: (v[0] if isinstance(v, list) and v else v) for k, v in mx.items() if k not in ("include", "exclude")}
511
+ if not matrix and isinstance(mx.get("include"), list) and mx["include"]:
512
+ matrix = dict(mx["include"][0])
513
+ ros = runner_os(job.get("runs-on", ""), matrix)
514
+ if ros == "macOS" and HOST != "macos":
515
+ _notrun("job %s runs on macOS and this is %s; run it on a Mac" % (jid, HOST))
516
+ if ros == "Windows" and HOST not in ("windows", "wsl"):
517
+ _notrun("job %s runs on Windows and this is %s; run it on Windows or WSL" % (jid, HOST))
518
+ win = ros == "Windows" and HOST == "wsl" # the steps run on the Windows side, called from WSL
519
+ defaults = ((wf.get("defaults") or {}).get("run") or {}).copy()
520
+ defaults.update(((job.get("defaults") or {}).get("run") or {}))
521
+ steps = job.get("steps") or []
522
+
523
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
524
+ logdir = os.path.join("tests/.artifacts/ci-repro", stamp)
525
+ root = work_root()
526
+ work, cache, temp = os.path.join(root, "work"), os.path.join(root, "caches"), os.path.join(root, "tmp")
527
+ ctx = {"outputs": {}, "outcome": {}, "env": {}, "matrix": matrix, "runner_os": ros,
528
+ "temp_shell": winpath(temp) if win else temp, "work_shell": winpath(work) if win else work}
529
+
530
+ plan = []
531
+ for n, st in enumerate(steps, 1):
532
+ st = st or {}
533
+ name = st.get("name") or (("Run " + str(st["run"]).strip().splitlines()[0]) if st.get("run") else st.get("uses", "step %d" % n))
534
+ if "uses" in st:
535
+ u = str(st["uses"])
536
+ why = ("the copy stands in for the checkout" if u.startswith("actions/checkout") else
537
+ "left out on purpose: the caches start empty" if "/cache" in u else
538
+ "the tools installed here stand in" if "/setup-" in u else "an action is not run here")
539
+ plan.append((n, name, st, "skip", "uses %s — %s" % (u, why)))
540
+ continue
541
+ body = str(st.get("run") or "")
542
+ lines = [l for l in body.splitlines() if l.strip() and not l.strip().startswith("#")]
543
+ sec = secrets_in({"run": body, "env": st.get("env"), "if": st.get("if")})
544
+ if sec:
545
+ plan.append((n, name, st, "skip", "needs secrets.%s; this machine's own credentials stand in" % ", secrets.".join(sec)))
546
+ elif lines and all(SETUP_RX.match(l) or l.rstrip().endswith(("`", "\\")) for l in lines) and SETUP_RX.match(lines[0]) and not opt["setup"]:
547
+ plan.append((n, name, st, "skip", "sets up the machine; what is installed here stands in (--run-setup runs it)"))
548
+ else:
549
+ plan.append((n, name, st, "run", ""))
550
+ caches = sorted({w for _, _, w in CACHES if w})
551
+ if opt["list"]:
552
+ say("workflow %s, job %s (runs on %s; here: %s)" % (wf_path, jid, ros, HOST))
553
+ for n, name, st, what, why in plan:
554
+ say(" %-4s %2d %s%s" % (what, n, name, (" — " + why) if why else ""))
555
+ say("empty caches: %s; the copy leaves out everything .gitignore ignores" % ", ".join(caches))
556
+ return 0
557
+
558
+ os.makedirs(logdir, exist_ok=True)
559
+ shutil.rmtree(root, ignore_errors=True)
560
+ for d in (work, temp):
561
+ os.makedirs(d, exist_ok=True)
562
+ say("copying the repository to %s (without what .gitignore ignores) …" % work)
563
+ try:
564
+ clean_copy(".", work)
565
+ except OSError as e:
566
+ _notrun("could not copy the repository: %s" % e)
567
+
568
+ env = dict(os.environ)
569
+ wslenv = [x for x in env.get("WSLENV", "").split(":") if x]
570
+ pathvars = []
571
+ for var, folder, _ in CACHES:
572
+ os.makedirs(os.path.join(cache, folder), exist_ok=True)
573
+ env[var] = os.path.join(cache, folder); pathvars.append(var)
574
+ env["MAVEN_OPTS"] = (env.get("MAVEN_OPTS", "") + " -Dmaven.repo.local=" + (winpath(os.path.join(cache, "m2")) if win else os.path.join(cache, "m2"))).strip()
575
+ env.update({"CI": "true", "GITHUB_WORKSPACE": work, "RUNNER_TEMP": temp, "RUNNER_OS": ros})
576
+ pathvars += ["GITHUB_WORKSPACE", "RUNNER_TEMP"]
577
+ plain = ["CI", "RUNNER_OS", "MAVEN_OPTS"]
578
+ for src in (wf.get("env") or {}, job.get("env") or {}):
579
+ for k, v in src.items():
580
+ try:
581
+ ctx["env"][k] = expand(v, ctx)
582
+ except (NeedsSecret, Unknown):
583
+ ctx["env"][k] = ""
584
+ job_timeout = float(job.get("timeout-minutes") or 360) * 60
585
+ t_job = time.time()
586
+ verdict, ran = None, 0
587
+ for n, name, st, what, why in plan:
588
+ if what == "skip":
589
+ say("skip %2d %s — %s" % (n, name, why)); continue
590
+ sid = st.get("id") or "_%d" % n
591
+ if "if" in st:
592
+ cond = str(st["if"]).strip()
593
+ cond = re.sub(r"^\$\{\{\s*(.*?)\s*\}\}$", r"\1", cond, flags=re.S)
594
+ try:
595
+ if not truthy(evaluate(cond, ctx)):
596
+ say("skip %2d %s — its condition is false here (%s)" % (n, name, cond)); ctx["outcome"][sid] = "skipped"; continue
597
+ except NeedsSecret:
598
+ say("skip %2d %s — its condition reads a secret" % (n, name)); continue
599
+ except Unknown:
600
+ say("note %2d %s — condition %r not understood here; the step runs" % (n, name, cond))
601
+ try:
602
+ body = expand(st.get("run", ""), ctx)
603
+ senv = {k: expand(v, ctx) for k, v in (st.get("env") or {}).items()}
604
+ except Unknown as e:
605
+ say("skip %2d %s — uses ${{ %s }}, which this script cannot know here" % (n, name, e)); continue
606
+ files = {k: os.path.join(temp, "%s_%d" % (k.lower(), n)) for k in ("GITHUB_OUTPUT", "GITHUB_ENV", "GITHUB_PATH", "GITHUB_STEP_SUMMARY")}
607
+ for p in files.values():
608
+ open(p, "w").close()
609
+ senv_all = dict(env); senv_all.update(ctx["env"]); senv_all.update(senv); senv_all.update(files)
610
+ names = plain + list(ctx["env"]) + list(senv)
611
+ if win:
612
+ senv_all["WSLENV"] = ":".join(wslenv + [v + "/p" for v in pathvars + list(files)] + names)
613
+ shell = str(st.get("shell") or defaults.get("shell") or ("pwsh" if ros == "Windows" else "bash"))
614
+ wd = os.path.normpath(os.path.join(work, expand(st.get("working-directory") or defaults.get("working-directory") or ".", ctx)))
615
+ sp = os.path.join(temp, "step_%d" % n)
616
+ if shell in ("pwsh", "powershell"):
617
+ sp += ".ps1"
618
+ open(sp, "w", encoding="utf-8-sig").write("$ErrorActionPreference = 'stop'\n%s\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }\n" % body)
619
+ exe = (shell + ".exe") if win else shell
620
+ cmd = [exe, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", winpath(sp) if win else sp]
621
+ elif shell == "cmd":
622
+ sp += ".cmd"
623
+ open(sp, "w", newline="\r\n").write("@echo off\n" + body + "\n")
624
+ cmd = ["cmd.exe", "/D", "/E:ON", "/V:OFF", "/S", "/C", "CALL " + (winpath(sp) if win else sp)]
625
+ elif shell == "python":
626
+ sp += ".py"; open(sp, "w").write(body); cmd = ["python3", sp]
627
+ elif shell.startswith(("bash", "sh")):
628
+ sp += ".sh"; open(sp, "w", newline="\n").write(body + "\n")
629
+ cmd = ["bash", "--noprofile", "--norc", "-eo", "pipefail", sp] if shell.startswith("bash") else ["sh", "-e", sp]
630
+ else:
631
+ say("skip %2d %s — shell %r is not supported here" % (n, name, shell)); continue
632
+ if win and shell in ("bash", "sh"):
633
+ say("note %2d %s — a bash step of a Windows job runs in WSL bash here, not Git Bash" % (n, name))
634
+ log = os.path.join(logdir, "step-%02d.log" % n)
635
+ t0 = time.time()
636
+ left = job_timeout - (t0 - t_job)
637
+ tmo = min(left, float(st.get("timeout-minutes") or 1e9) * 60)
638
+ say("run %2d %s …" % (n, name))
639
+ with open(log, "w", encoding="utf-8") as lf:
640
+ try:
641
+ rc = subprocess.run(cmd, cwd=wd, env=senv_all, stdout=lf, stderr=subprocess.STDOUT, timeout=max(tmo, 1)).returncode
642
+ except subprocess.TimeoutExpired:
643
+ rc = 124
644
+ except OSError as e:
645
+ lf.write(str(e)); rc = 127
646
+ secs = int(time.time() - t0)
647
+ ctx["outputs"][sid] = parse_kv_file(files["GITHUB_OUTPUT"])
648
+ ctx["env"].update(parse_kv_file(files["GITHUB_ENV"]))
649
+ ran += 1
650
+ if rc == 0:
651
+ ctx["outcome"][sid] = "success"
652
+ say("ok %2d %s (%d s)" % (n, name, secs)); continue
653
+ ctx["outcome"][sid] = "failure"
654
+ text = read_text(log)
655
+ first = next((l.strip() for l in text.splitlines() if re.search(r"\berror\b|\bERR!|Error:|FAILED|fatal:", l, re.I)), "")
656
+ if not first:
657
+ first = (text.strip().splitlines() or ["(no output)"])[-1].strip()
658
+ first = re.sub(r"\s+", " ", first)[:300]
659
+ if rc == 124:
660
+ first = "timed out after %d min" % (tmo // 60)
661
+ if st.get("continue-on-error") is True:
662
+ say("FAIL %2d %s (%d s, exit %d; continue-on-error) — %s" % (n, name, secs, rc, first)); continue
663
+ say("FAIL %2d %s (%d s, exit %d) — %s" % (n, name, secs, rc, first))
664
+ verdict = ("FAIL job %s: step %d \"%s\" failed (exit %d) with empty caches — %s — log %s"
665
+ % (jid, n, name, rc, first, log))
666
+ break
667
+ if not opt["keep"]:
668
+ shutil.rmtree(root, ignore_errors=True)
669
+ try:
670
+ os.rmdir(os.path.dirname(root)) # tf-ci-repro/, when no other run is using it
671
+ except OSError:
672
+ pass
673
+ else:
674
+ say("kept the copy and its caches at %s" % root)
675
+ if verdict:
676
+ say(verdict); return 1
677
+ if ran == 0:
678
+ _notrun("job %s has no step this script could run" % jid)
679
+ say("PASS job %s: %d run step(s) passed on a clean copy with empty caches (%s) — logs %s"
680
+ % (jid, ran, ", ".join(caches), logdir))
681
+ return 0
682
+
683
+
684
+ if __name__ == "__main__":
685
+ sys.exit(main())