@techierathore/techieflow 1.0.4 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/TechieFlow/agents/flow-master.md +2 -0
- package/.claude/commands/TechieFlow/agents/verifier.md +2 -2
- package/.claude/commands/TechieFlow/tasks/_develop-mode.md +34 -0
- package/.claude/commands/TechieFlow/tasks/_owner-language.md +1 -0
- package/.claude/commands/TechieFlow/tasks/_status-update-gate.md +4 -3
- package/.claude/commands/TechieFlow/tasks/_yolo-mode.md +2 -2
- package/.claude/commands/TechieFlow/tasks/amend-docs.md +2 -2
- package/.claude/commands/TechieFlow/tasks/day1-brownfield.md +3 -3
- package/.claude/commands/TechieFlow/tasks/day1-greenfield.md +5 -5
- package/.claude/commands/TechieFlow/tasks/develop-end-to-end.md +30 -0
- package/.claude/commands/TechieFlow/tasks/fix-issues.md +3 -2
- package/.claude/commands/TechieFlow/tasks/generate-html.md +2 -2
- package/.claude/commands/TechieFlow/tasks/handoff-phase.md +2 -2
- package/.claude/commands/TechieFlow/tasks/mockups.md +2 -2
- package/.claude/commands/TechieFlow/tasks/productguide.md +2 -2
- package/.claude/commands/TechieFlow/tasks/render-workflow-docs.md +1 -1
- package/.claude/commands/TechieFlow/tasks/triage-and-fix.md +2 -2
- package/.claude/commands/TechieFlow/tasks/triage-issues.md +2 -2
- package/.claude/commands/TechieFlow/tasks/verify-phase.md +12 -11
- package/.opencode/plugin/techieflow.js +194 -14
- package/.tfcore/agents/flow-master.md +2 -0
- package/.tfcore/agents/verifier.md +2 -2
- package/.tfcore/core-config.yaml +2 -1
- package/.tfcore/hooks/block-git.sh +4 -2
- package/.tfcore/hooks/guard-build.sh +5 -1
- package/.tfcore/hooks/guard-db.sh +112 -13
- package/.tfcore/hooks/guard-status-html.sh +86 -2
- package/.tfcore/hooks/guard-verify-deps.sh +16 -1
- package/.tfcore/hooks/guard-verify.sh +8 -3
- package/.tfcore/hooks/sweep-artifacts.sh +46 -0
- package/.tfcore/hooks/yolo-allow.sh +27 -0
- package/.tfcore/standards/blazor-zoom.cs.txt +191 -0
- package/.tfcore/standards/coding-standards-dotnet.md +31 -3
- package/.tfcore/standards/owner-words.txt +23 -0
- package/.tfcore/tasks/_develop-mode.md +34 -0
- package/.tfcore/tasks/_owner-language.md +1 -0
- package/.tfcore/tasks/_status-update-gate.md +4 -3
- package/.tfcore/tasks/_yolo-mode.md +2 -2
- package/.tfcore/tasks/amend-docs.md +2 -2
- package/.tfcore/tasks/day1-brownfield.md +3 -3
- package/.tfcore/tasks/day1-greenfield.md +5 -5
- package/.tfcore/tasks/develop-end-to-end.md +30 -0
- package/.tfcore/tasks/fix-issues.md +3 -2
- package/.tfcore/tasks/generate-html.md +2 -2
- package/.tfcore/tasks/handoff-phase.md +2 -2
- package/.tfcore/tasks/mockups.md +2 -2
- package/.tfcore/tasks/productguide.md +2 -2
- package/.tfcore/tasks/render-workflow-docs.md +1 -1
- package/.tfcore/tasks/triage-and-fix.md +2 -2
- package/.tfcore/tasks/triage-issues.md +2 -2
- package/.tfcore/tasks/verify-phase.md +12 -11
- package/.tfcore/telemetry/SCHEMA.md +10 -2
- package/.tfcore/telemetry/install-metrics.sh +3 -2
- package/.tfcore/telemetry/tf-metrics.sh +38 -2
- package/.tfcore/templates/stack-defaults/dotnet.md +3 -1
- package/.tfcore/templates/v4custom/app-agents-md-tmpl.md +6 -0
- package/.tfcore/templates/v4custom/app-architecture-tmpl.md +5 -0
- package/.tfcore/templates/v4custom/app-brd-tmpl.md +7 -4
- package/.tfcore/templates/v4custom/app-checklist-tmpl.md +11 -0
- package/.tfcore/templates/v4custom/app-phase-brd-tmpl.md +94 -0
- package/.tfcore/templates/v4custom/app-phase-uidesign-tmpl.md +60 -0
- package/.tfcore/templates/v4custom/app-phases-tmpl.md +2 -1
- package/.tfcore/templates/v4custom/app-uidesign-tmpl.md +4 -2
- package/.tfcore/templates/v4custom/build-subagent-prompt.md +2 -2
- package/.tfcore/templates/v4custom/html-render-shell.md +6 -2
- package/.tfcore/utils/__pycache__/tf-devguide-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-doc-check.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-split-brd.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-status-facts.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-list.cpython-312.pyc +0 -0
- package/.tfcore/utils/tf-appium.mjs +179 -0
- package/.tfcore/utils/tf-assets-browser.mjs +96 -0
- package/.tfcore/utils/tf-assets.sh +97 -12
- package/.tfcore/utils/tf-brd-status.py +14 -5
- package/.tfcore/utils/tf-build-list.py +95 -12
- package/.tfcore/utils/tf-build-list.sh +2 -1
- package/.tfcore/utils/tf-build.sh +163 -15
- package/.tfcore/utils/tf-checklist-edit.py +21 -3
- package/.tfcore/utils/tf-ci-repro.py +685 -0
- package/.tfcore/utils/tf-ci-repro.sh +24 -0
- package/.tfcore/utils/tf-day1-files.py +55 -28
- package/.tfcore/utils/tf-develop.sh +264 -0
- package/.tfcore/utils/tf-devguide-list.py +53 -24
- package/.tfcore/utils/tf-doc-check.py +218 -26
- package/.tfcore/utils/tf-doc-tests.sh +116 -0
- package/.tfcore/utils/tf-emit.sh +132 -45
- package/.tfcore/utils/tf-feedback.sh +13 -0
- package/.tfcore/utils/tf-fix-close.sh +87 -31
- package/.tfcore/utils/tf-goal.sh +12 -12
- package/.tfcore/utils/tf-harness.sh +9 -4
- package/.tfcore/utils/tf-lock.sh +29 -0
- package/.tfcore/utils/tf-log-miss.py +34 -8
- package/.tfcore/utils/tf-login.mjs +167 -0
- package/.tfcore/utils/tf-maccatalyst-check.sh +92 -0
- package/.tfcore/utils/tf-menus.mjs +401 -0
- package/.tfcore/utils/tf-misses-md.py +10 -4
- package/.tfcore/utils/tf-mockup-parity.mjs +763 -71
- package/.tfcore/utils/tf-mockup-parity.sh +25 -2
- package/.tfcore/utils/tf-owner-text.py +233 -0
- package/.tfcore/utils/tf-owner-text.sh +14 -0
- package/.tfcore/utils/tf-phase.sh +38 -4
- package/.tfcore/utils/tf-portable.sh +230 -0
- package/.tfcore/utils/tf-render-html.py +79 -5
- package/.tfcore/utils/tf-req-ids.py +36 -0
- package/.tfcore/utils/tf-selfcheck.sh +39 -6
- package/.tfcore/utils/tf-split-brd.py +11 -1
- package/.tfcore/utils/tf-stack-check.sh +3 -0
- package/.tfcore/utils/tf-status-evidence.sh +11 -9
- package/.tfcore/utils/tf-status-facts.py +160 -45
- package/.tfcore/utils/tf-theme.mjs +189 -0
- package/.tfcore/utils/tf-triage.py +74 -17
- package/.tfcore/utils/tf-triage.sh +2 -0
- package/.tfcore/utils/tf-verify-boot.sh +529 -54
- package/.tfcore/utils/tf-verify-emit.sh +57 -17
- package/.tfcore/utils/tf-verify-env.sh +33 -4
- package/.tfcore/utils/tf-verify-list.py +290 -24
- package/.tfcore/utils/tf-verify-list.sh +3 -1
- package/.tfcore/utils/tf-verify-native.mjs +307 -0
- package/.tfcore/utils/tf-verify-screens.mjs +341 -44
- package/.tfcore/utils/tf-verify-screens.sh +23 -5
- package/.tfcore/utils/tf-verify-tests.sh +310 -19
- package/.tfcore/utils/tf-verify-verdict.py +163 -21
- package/.tfcore/utils/tf-verify-zoom.mjs +193 -0
- package/.tfcore/utils/tf-verify-zoom.sh +29 -0
- package/.tfcore/utils/tf-yolo-alias.sh +84 -0
- package/.tfcore/utils/tf-yolo-perms.py +135 -0
- package/.tfcore/utils/tf-yolo-start.sh +44 -0
- package/.tfcore/utils/tf-yolo.sh +31 -3
- package/.tfcore/utils/tf_defect.py +27 -0
- package/.tfcore/utils/tf_feedback.py +272 -0
- package/.tfcore/utils/tf_roadmap.py +35 -0
- package/.tfcore/utils/tf_stack_check.py +93 -0
- package/README.md +6 -2
- package/docs/TechieFlow-Installation.md +5 -3
- package/opencode.jsonc +6 -2
- package/package.json +1 -1
- package/scripts/install.mjs +13 -2
- package/.tfcore/utils/__pycache__/tf-brd-status.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-bind.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-codex-telemetry.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-day1-files.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-log-miss.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-misses-md.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-mockups-locate.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-opencode-audit.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-render-html.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-triage.cpython-312.pyc +0 -0
- package/.tfcore/utils/__pycache__/tf-verify-verdict.cpython-312.pyc +0 -0
|
@@ -5,6 +5,28 @@
|
|
|
5
5
|
# --screen login=/login --screen harness=/harness --screen export=/export \
|
|
6
6
|
# --cookie 'AuthCookie=<value from your Playwright login>' \
|
|
7
7
|
# --json-out tests/.artifacts/mockup-parity/run.json
|
|
8
|
+
# A sign-in the page keeps for itself sets no cookie (AppManager TF-011): give it
|
|
9
|
+
# --login-path /login --user <u> --password <p> (or --storage-state <file>)
|
|
10
|
+
# and each screen is opened in a tab that signed in through the form, the way
|
|
11
|
+
# tf-verify-screens.sh does; `widths[].reached` in the JSON says how a 401 screen was reached.
|
|
12
|
+
# --list tests/.artifacts/verify/list.json takes each screen's mockup path from the list
|
|
13
|
+
# tf-verify-list.sh wrote, so a mockup in a subfolder is found (Lekhak TF-022); with no --screen
|
|
14
|
+
# it drives every listed screen. Without a list, a mockup in a subfolder is used when it is the only one.
|
|
15
|
+
# A desktop head (Lekhak TF-006): --cdp http://<host>:9223 in place of --base attaches to the
|
|
16
|
+
# running app, as tf-verify-screens.sh --cdp does, and opens each route by pushState.
|
|
17
|
+
# The app is compared in the mockup's theme: the mockup's data-*theme/mode/scheme attributes on
|
|
18
|
+
# <html> and <body>, and a light/dark mode class there (class="dark", Lekhak TF-013), are set on the
|
|
19
|
+
# app page for the comparison and put back after (Lekhak TF-010);
|
|
20
|
+
# Then the screen is compared again in the other theme (Lekhak TF-024), the mockup drawn in it too:
|
|
21
|
+
# --themes light,dark runs both, light or dark alone only that one, and auto (the default) runs the
|
|
22
|
+
# other once the app shows it has it. Colour is not compared in a theme the mockup does not draw.
|
|
23
|
+
# Each finding of that pass carries "theme"; the result lists "themes" it was compared in.
|
|
24
|
+
# --keep-app-theme compares the theme the app shows, and only that one. A mockup box marked data-tf-state or
|
|
25
|
+
# data-state-testid (another state of the screen) is left out of the comparison (Lekhak TF-011),
|
|
26
|
+
# and so are its icons, badges and text when a box around it is compared (Chatur TF-001).
|
|
27
|
+
# A box marked data-tf-sample, or data-tf-state="sample-data" (Chatur TF-005), is sample data: compared
|
|
28
|
+
# when the app draws something in its place,
|
|
29
|
+
# otherwise listed in coverage.not_measured and taken off the boxes around it (Chatur TF-002).
|
|
8
30
|
#
|
|
9
31
|
# WHAT IT ASKS: "does the built screen carry the structure its approved mockup
|
|
10
32
|
# draws?" — at the same viewports, comparing STRUCTURE, never pixels. Pixel diffing
|
|
@@ -47,7 +69,7 @@ set -uo pipefail
|
|
|
47
69
|
|
|
48
70
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
49
71
|
|
|
50
|
-
case "${1:-}" in -h|--help) sed -n '2,
|
|
72
|
+
case "${1:-}" in -h|--help) sed -n '2,56p' "$0"; exit 0 ;; esac
|
|
51
73
|
|
|
52
74
|
command -v node >/dev/null 2>&1 || {
|
|
53
75
|
echo "tf-mockup-parity: node is required (verify-phase §1 installs the verify environment)." >&2
|
|
@@ -62,4 +84,5 @@ node -e "import('playwright').then(()=>process.exit(0)).catch(()=>process.exit(1
|
|
|
62
84
|
exit 4
|
|
63
85
|
}
|
|
64
86
|
|
|
65
|
-
|
|
87
|
+
source "$HERE/tf-lock.sh"; tf_take_lock mockup-parity # one browser check at a time (TF-048)
|
|
88
|
+
node "$HERE/tf-mockup-parity.mjs" "$@"
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""tf-owner-text.py — check text written for the owner (MISS-TechieFlow-20260911-03).
|
|
3
|
+
|
|
4
|
+
bash .tfcore/utils/tf-owner-text.sh [--root <dir>] [--final] <file> ...
|
|
5
|
+
bash .tfcore/utils/tf-owner-text.sh [--root <dir>] [--final] --stdin # the closing message
|
|
6
|
+
|
|
7
|
+
Four checks, one per mistake that reached the owner in TfLens's hand-off of 2026-09-11
|
|
8
|
+
(MISS-TfLens-20260911-01 to -03, and a fourth: two problems fixed upstream reported as open):
|
|
9
|
+
|
|
10
|
+
words a word from .tfcore/standards/owner-words.txt, or a script's internal name such as
|
|
11
|
+
duration_s or PHASES_TOP_KEYS, in a sentence the owner reads
|
|
12
|
+
upstream an upstream problem (an entry id in docs/*-Feedback.md: TF-022, TR-010 …) that is
|
|
13
|
+
still open sits in a table saying what it affects and whether it blocks or breaks
|
|
14
|
+
anything, and appears in a code block, which is the prompt that gets it fixed; one the
|
|
15
|
+
feedback file records as fixed upstream or closed is described as fixed or closed
|
|
16
|
+
commands a framework command named in a sentence (*verify) appears in a code block holding
|
|
17
|
+
the line to paste
|
|
18
|
+
final with --final, the text is a command's closing message and carries a code block: the
|
|
19
|
+
next prompt
|
|
20
|
+
|
|
21
|
+
Text inside ``` blocks is what the owner pastes, so the words check skips it. A document that has
|
|
22
|
+
its own template (BRD, checklist, feedback file …) is skipped: tf-doc-check.sh owns its shape.
|
|
23
|
+
Exit 0 clean, 1 a FAIL, 2 could not run. Python 3 standard library only.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import glob
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
33
|
+
ANY_ID = re.compile(r"(?<![\w-])([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d+)\b")
|
|
34
|
+
SCRIPT_NAME = re.compile(r"(?<![\w/.\-])([A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+)(?![\w/\-]|\.\w)")
|
|
35
|
+
COMMAND = re.compile(r"(?<![\w*])\*([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b")
|
|
36
|
+
# a sentence that puts a command in front of the owner as work still to do
|
|
37
|
+
TO_RUN = re.compile(r"(?i)\b(run|rerun|re-run|pending|next|not (?:yet )?(?:been )?run|still|should|must|"
|
|
38
|
+
r"needs?|to do|waiting|before|then)\b")
|
|
39
|
+
COMMANDS = {"verify", "build-phase", "fix-issues", "triage-issues", "triage-and-fix", "amend-docs",
|
|
40
|
+
"refresh-status", "handoff-phase", "log-miss", "deploy-checklist", "day1-greenfield",
|
|
41
|
+
"day1-brownfield", "devguide", "productguide", "metrics", "split-brd", "mockups", "yolo"}
|
|
42
|
+
TEMPLATED = re.compile(
|
|
43
|
+
r"(?i)(?:-P\d+)?-(BRD|Architecture|UIDesign|Checklist|Deployment-Checklist(?:-[\w]+)?|Coding-Standards|"
|
|
44
|
+
r"UsageGuide|Usage-Guide|DevGuide|ProductGuide|Phases|Brief|Decision-Request|[\w.]+-Feedback|Misses)\.md$"
|
|
45
|
+
r"|(?:^|/)PROJECT-STATUS\.md$")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_words(root):
|
|
49
|
+
for base in (os.path.join(root, ".tfcore", "standards"), os.path.join(HERE, "..", "standards")):
|
|
50
|
+
p = os.path.join(base, "owner-words.txt")
|
|
51
|
+
if os.path.isfile(p):
|
|
52
|
+
out = {}
|
|
53
|
+
for line in open(p, encoding="utf-8"):
|
|
54
|
+
if line.strip() and not line.lstrip().startswith("#") and "|" in line:
|
|
55
|
+
w, say = (x.strip() for x in line.split("|", 1))
|
|
56
|
+
out[w.lower()] = say
|
|
57
|
+
return out
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def upstream_state(root):
|
|
62
|
+
"""Every entry id in the project's feedback files -> (state, where), from the one reader
|
|
63
|
+
every script uses (tf_feedback.py), so this check and PROJECT-STATUS can never disagree."""
|
|
64
|
+
sys.path.insert(0, HERE)
|
|
65
|
+
import tf_feedback
|
|
66
|
+
return tf_feedback.state_map(root)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def split_text(text):
|
|
70
|
+
"""-> (prose lines [(n, line)], the text inside code blocks)."""
|
|
71
|
+
prose, fenced, fence = [], [], None
|
|
72
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
73
|
+
m = re.match(r"^\s*(```|~~~)", line)
|
|
74
|
+
if m:
|
|
75
|
+
fence = None if fence == m.group(1) else (fence or m.group(1))
|
|
76
|
+
continue
|
|
77
|
+
(fenced if fence else prose).append((n, line) if not fence else line)
|
|
78
|
+
return prose, "\n".join(fenced)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def units(prose):
|
|
82
|
+
"""Group prose lines into the unit a sentence is read in: a table row (with its table's
|
|
83
|
+
header) or a paragraph. -> [(first line no, text, header or None)]"""
|
|
84
|
+
out, para, header, in_table = [], [], None, False
|
|
85
|
+
|
|
86
|
+
def flush():
|
|
87
|
+
if para:
|
|
88
|
+
out.append((para[0][0], "\n".join(t for _n, t in para), None))
|
|
89
|
+
para.clear()
|
|
90
|
+
|
|
91
|
+
for n, line in prose:
|
|
92
|
+
if line.lstrip().startswith("|"):
|
|
93
|
+
flush()
|
|
94
|
+
if not in_table:
|
|
95
|
+
header, in_table = line, True
|
|
96
|
+
elif not re.match(r"^\s*\|?\s*:?-{2,}", line):
|
|
97
|
+
out.append((n, line, header))
|
|
98
|
+
continue
|
|
99
|
+
in_table = False
|
|
100
|
+
if line.strip():
|
|
101
|
+
para.append((n, line))
|
|
102
|
+
else:
|
|
103
|
+
flush()
|
|
104
|
+
flush()
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def next_command(root):
|
|
109
|
+
p = os.path.join(root, "PROJECT-STATUS.md")
|
|
110
|
+
if not os.path.isfile(p):
|
|
111
|
+
return ""
|
|
112
|
+
m = re.search(r"(?ms)^##\s+Next command to run.*?```[^\n]*\n(.*?)```", open(p, encoding="utf-8").read())
|
|
113
|
+
return m.group(1).strip().splitlines()[0] if m and m.group(1).strip() else ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def check(text, label, root, final=False, words=None, ups=None):
|
|
117
|
+
fails = []
|
|
118
|
+
fail = lambda n, msg: fails.append(f"FAIL {label}{':' + str(n) if n else ''}: {msg}")
|
|
119
|
+
words = load_words(root) if words is None else words
|
|
120
|
+
ups = upstream_state(root) if ups is None else ups
|
|
121
|
+
prose, fenced = split_text(text)
|
|
122
|
+
|
|
123
|
+
# 1. words
|
|
124
|
+
seen = set()
|
|
125
|
+
for n, line in prose:
|
|
126
|
+
plain = re.sub(r"`[^`]*`", " ", re.sub(r"https?://\S+|\]\([^)]*\)", " ", line))
|
|
127
|
+
plain = re.sub(r'"[^"\n]{1,30}"|“[^”\n]{1,30}”', " ", plain) # a word quoted to name it is not used
|
|
128
|
+
for w, say in words.items():
|
|
129
|
+
if w not in seen and re.search(rf"(?i)(?<![\w-]){re.escape(w)}s?(?![\w-])", plain):
|
|
130
|
+
seen.add(w)
|
|
131
|
+
fail(n, f'"{w}" is a word the owner has to look up; say "{say}"')
|
|
132
|
+
for name in SCRIPT_NAME.findall(re.sub(r"https?://\S+|\]\([^)]*\)", " ", line)):
|
|
133
|
+
if name not in seen:
|
|
134
|
+
seen.add(name)
|
|
135
|
+
fail(n, f'"{name}" is a name inside a script; say in words what it is')
|
|
136
|
+
|
|
137
|
+
# 2. upstream problems. A fixed or closed one must read as such wherever it is named; an open
|
|
138
|
+
# one must be explained once, in a table row saying what it affects and whether it blocks or
|
|
139
|
+
# breaks anything, and must appear in the prompt that gets it fixed.
|
|
140
|
+
us = units(prose)
|
|
141
|
+
mentions = {}
|
|
142
|
+
for n, txt, header in us:
|
|
143
|
+
for rid in dict.fromkeys(ANY_ID.findall(txt)):
|
|
144
|
+
if rid in ups:
|
|
145
|
+
mentions.setdefault(rid, []).append((n, txt, header))
|
|
146
|
+
for rid, ms in sorted(mentions.items(), key=lambda kv: kv[1][0][0]):
|
|
147
|
+
st, where = ups[rid]
|
|
148
|
+
if st in ("fixed", "closed"):
|
|
149
|
+
# read the sentence that names it, or its table row: "TF-901 is filed. X is fixed." is wrong
|
|
150
|
+
said = lambda txt, h: [txt] if h is not None else [s for s in re.split(r"(?<=[.!?])\s+", txt) if rid in s]
|
|
151
|
+
wrong = next((n for n, txt, h in ms
|
|
152
|
+
if not all(re.search(r"(?i)\b(fixed|closed)\b", s) for s in said(txt, h))), None)
|
|
153
|
+
if wrong is not None:
|
|
154
|
+
fail(wrong, f"{rid} is written about as an open problem, but {where} records it as "
|
|
155
|
+
f"{'fixed upstream' if st == 'fixed' else 'closed'}; say so, and say what re-checks it here")
|
|
156
|
+
continue
|
|
157
|
+
explained = any(h is not None and "affect" in h.lower() and re.search(r"(?i)block|break", h)
|
|
158
|
+
for _n, _t, h in ms)
|
|
159
|
+
if not explained:
|
|
160
|
+
fail(ms[0][0], f"{rid} is named without saying what it affects and whether it blocks or breaks "
|
|
161
|
+
f"anything: put it in a table with the columns Problem | What it affects | Does it block or break anything")
|
|
162
|
+
elif not re.search(rf"(?<![\w-]){re.escape(rid)}\b", fenced):
|
|
163
|
+
fail(ms[0][0], f"{rid} is open and no code block gives the prompt that gets it fixed")
|
|
164
|
+
|
|
165
|
+
# 3. commands: one presented as something to run needs the line to paste. Naming a command
|
|
166
|
+
# to say what it did ("*build-phase means build every unfinished row") needs nothing.
|
|
167
|
+
known = COMMANDS | {os.path.splitext(os.path.basename(p))[0]
|
|
168
|
+
for p in glob.glob(os.path.join(root, ".tfcore", "tasks", "*.md"))
|
|
169
|
+
if not os.path.basename(p).startswith("_")}
|
|
170
|
+
named = {}
|
|
171
|
+
for n, txt, _h in us:
|
|
172
|
+
plain = re.sub(r"`[^`]*`", " ", txt) # `run-void` is a name, not a cue
|
|
173
|
+
plain = re.sub(r"(?i)\b(?:do not|don't|never|no need to|need not)\s+run\b", " ", plain)
|
|
174
|
+
if not TO_RUN.search(plain):
|
|
175
|
+
continue
|
|
176
|
+
for c in COMMAND.findall(txt):
|
|
177
|
+
if c in known:
|
|
178
|
+
named.setdefault(c, n)
|
|
179
|
+
for c, n in sorted(named.items(), key=lambda kv: kv[1]):
|
|
180
|
+
if not re.search(rf"\*{re.escape(c)}\b", fenced):
|
|
181
|
+
fail(n, f"*{c} is named, but no code block holds the line to paste for it "
|
|
182
|
+
f"(tf-status-facts.sh prints it in both harness forms)")
|
|
183
|
+
|
|
184
|
+
# 4. a command's closing message ends by handing over the next prompt
|
|
185
|
+
if final and not fenced.strip():
|
|
186
|
+
nc = next_command(root)
|
|
187
|
+
fail(0, "the closing message gives no next prompt; end with a code block holding the line to paste"
|
|
188
|
+
+ (f" — PROJECT-STATUS.md says: {nc}" if nc else ""))
|
|
189
|
+
return fails
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv):
|
|
193
|
+
root, final, stdin, files = os.getcwd(), False, False, []
|
|
194
|
+
it = iter(argv)
|
|
195
|
+
for a in it:
|
|
196
|
+
if a == "--root":
|
|
197
|
+
root = os.path.abspath(next(it, "."))
|
|
198
|
+
elif a == "--final":
|
|
199
|
+
final = True
|
|
200
|
+
elif a == "--stdin":
|
|
201
|
+
stdin = True
|
|
202
|
+
elif a in ("-h", "--help"):
|
|
203
|
+
print(__doc__)
|
|
204
|
+
return 0
|
|
205
|
+
else:
|
|
206
|
+
files.append(a)
|
|
207
|
+
if not stdin and not files:
|
|
208
|
+
print(__doc__)
|
|
209
|
+
return 2
|
|
210
|
+
words, ups, fails, checked = load_words(root), upstream_state(root), [], 0
|
|
211
|
+
if stdin:
|
|
212
|
+
fails += check(sys.stdin.read(), "the closing message", root, final, words, ups)
|
|
213
|
+
checked += 1
|
|
214
|
+
for f in files:
|
|
215
|
+
rel = os.path.relpath(os.path.abspath(f), root)
|
|
216
|
+
if TEMPLATED.search(rel.replace("\\", "/")):
|
|
217
|
+
print(f"SKIP {rel}: it has a template; tf-doc-check.sh checks it")
|
|
218
|
+
continue
|
|
219
|
+
if not os.path.isfile(f):
|
|
220
|
+
fails.append(f"FAIL {rel}: file not found")
|
|
221
|
+
continue
|
|
222
|
+
# a document handed over is held to the first three checks; ending on the next prompt is
|
|
223
|
+
# the closing message's job
|
|
224
|
+
fails += check(open(f, encoding="utf-8", errors="replace").read(), rel, root, False, words, ups)
|
|
225
|
+
checked += 1
|
|
226
|
+
for line in fails:
|
|
227
|
+
print(line)
|
|
228
|
+
print(f"tf-owner-text: {len(fails)} FAIL in {checked} text(s)")
|
|
229
|
+
return 1 if fails else 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# tf-owner-text.sh — check text written for the owner: plain words, every upstream problem with
|
|
3
|
+
# what it affects and the prompt that fixes it, every named command with the line to paste, and
|
|
4
|
+
# a command's closing message ending on the next prompt (MISS-TechieFlow-20260911-03).
|
|
5
|
+
#
|
|
6
|
+
# bash .tfcore/utils/tf-owner-text.sh [--root <dir>] [--final] <file> ...
|
|
7
|
+
# printf '%s' "<message>" | bash .tfcore/utils/tf-owner-text.sh --final --stdin
|
|
8
|
+
#
|
|
9
|
+
# The Stop hook runs it on a command's closing message and on every free-form document that
|
|
10
|
+
# message hands the owner. Details: tf-owner-text.py. Exit 0 clean, 1 a FAIL, 2 could not run.
|
|
11
|
+
set -u
|
|
12
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
command -v python3 >/dev/null 2>&1 || { echo "tf-owner-text: python3 is required" >&2; exit 2; }
|
|
14
|
+
exec python3 "$HERE/tf-owner-text.py" "$@"
|
|
@@ -38,17 +38,48 @@ case "${1:-}" in
|
|
|
38
38
|
NOW="$(sed -n 's/.*"started":"\([^"]*\)".*/\1/p' "$FILE")"
|
|
39
39
|
echo "tf-phase: started taken from the supervisor's marker ($NOW)" >&2
|
|
40
40
|
fi
|
|
41
|
-
|
|
41
|
+
# A command started inside another one (*fix-issues and *build-phase run *verify inline) keeps
|
|
42
|
+
# the outer command as "outer", written FIRST so a greedy `.*"started":"…"` read still takes this
|
|
43
|
+
# command's own start. tf-verify-emit.sh and tf-fix-close.sh read it to tell the two starts
|
|
44
|
+
# apart (TF-052: the inline verify took the fix's start and its record swallowed the fix's).
|
|
45
|
+
OUTER=""; CHAINED=""
|
|
46
|
+
if [[ -f "$FILE" && -z "$(find "$FILE" -mmin +1440 2>/dev/null)" ]]; then
|
|
47
|
+
OC="$(sed -n 's/.*"cmd":"\([^"]*\)".*/\1/p' "$FILE")"; OA="$(sed -n 's/.*"app":"\([^"]*\)".*/\1/p' "$FILE")"
|
|
48
|
+
OS="$(sed -n 's/.*"started":"\([^"]*\)".*/\1/p' "$FILE")"
|
|
49
|
+
[[ -n "$OC" && -n "$OS" && "$OC" != "$CMD" && "$OC" != "goal" && "$OS" != "$NOW" ]] \
|
|
50
|
+
&& OUTER="\"outer\":{\"cmd\":\"$OC\",\"app\":\"$OA\",\"started\":\"$OS\"},"
|
|
51
|
+
# A command the task files run inside another one keeps the outer command's document baseline
|
|
52
|
+
# (TrBlazeUI TF-002: *triage-and-fix started verify-phase and metrics-report, each wrote a new
|
|
53
|
+
# baseline, and the rows the triage had just added had their findings relabelled OLD). The pairs
|
|
54
|
+
# are the chains the task files name: build-phase, fix-issues and triage-and-fix run verify-phase;
|
|
55
|
+
# triage-and-fix runs metrics-report. The marker is never ended, so a standalone verify after a
|
|
56
|
+
# fix also keeps the fix's baseline: that can only make the gate stricter, never hide a finding.
|
|
57
|
+
OOC="$(sed -n 's/^{"outer":{"cmd":"\([^"]*\)".*/\1/p' "$FILE")"
|
|
58
|
+
case "$CMD" in
|
|
59
|
+
verify-phase|metrics-report)
|
|
60
|
+
for c in "$OC" "$OOC"; do
|
|
61
|
+
case "$c" in build-phase|fix-issues|triage-and-fix) CHAINED="$c"; break ;; esac
|
|
62
|
+
done ;;
|
|
63
|
+
esac
|
|
64
|
+
fi
|
|
65
|
+
printf '{%s"cmd":"%s","app":"%s","started":"%s"}\n' "$OUTER" "$CMD" "$APP" "$NOW" > "$FILE"
|
|
42
66
|
echo "$NOW"
|
|
43
67
|
echo "tf-phase: $CMD${APP:+ $APP} started $NOW (marker .tfcore/.session/phase.json)" >&2
|
|
44
68
|
# The document-check baseline (Sitting 4c, 2026-09-06): the findings the checklists and the
|
|
45
69
|
# status file already carry when the command starts are recorded, so the gate and the Stop
|
|
46
70
|
# hook block only on findings this command introduces (Schemas §7.1 decision 8: old projects
|
|
47
71
|
# are repaired through *amend-docs, never by whichever command happens to run next).
|
|
48
|
-
if [[ -
|
|
72
|
+
if [[ -n "$CHAINED" ]]; then
|
|
73
|
+
echo "tf-phase: $CMD runs inside $CHAINED — keeping its document baseline (findings since $CHAINED started stay FAIL)" >&2
|
|
74
|
+
elif [[ -x "$ROOT/.tfcore/utils/tf-doc-check.sh" || -f "$ROOT/.tfcore/utils/tf-doc-check.sh" ]]; then
|
|
75
|
+
# Every file `tf-doc-check.sh --app` reads, not only the checklists and the status file: *amend-docs
|
|
76
|
+
# and day-1 close on `--app`, so the BRD's, the Architecture's and the mockups' old findings read
|
|
77
|
+
# as new and blocked the run (Lekhak TF-023, 131 of them). A baseline only relabels what was
|
|
78
|
+
# already there, so a finding the command introduces still FAILs.
|
|
49
79
|
docs=(); for f in "$ROOT"/docs/*-Checklist.md "$ROOT"/PROJECT-STATUS.md; do [[ -f "$f" ]] && docs+=("$f"); done
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
appargs=(); [[ -n "$APP" && -d "$ROOT/docs" ]] && appargs=(--app "$APP")
|
|
81
|
+
if [[ ${#docs[@]} -gt 0 || ${#appargs[@]} -gt 0 ]]; then
|
|
82
|
+
( cd "$ROOT" && bash .tfcore/utils/tf-doc-check.sh --root "$ROOT" --baseline-write ${appargs[@]+"${appargs[@]}"} ${docs[@]+"${docs[@]}"} 2>/dev/null | tail -1 >&2 ) || true
|
|
52
83
|
fi
|
|
53
84
|
fi
|
|
54
85
|
# The framework's own scripts, against THIS project's real files, before the command
|
|
@@ -68,6 +99,9 @@ case "${1:-}" in
|
|
|
68
99
|
else
|
|
69
100
|
echo "tf-phase: framework self-check clean" >&2
|
|
70
101
|
fi
|
|
102
|
+
# upstream fixes this project has not re-checked: passed through whole, because "clean"
|
|
103
|
+
# alone let an agent report two fixed problems to the owner as open (2026-09-11)
|
|
104
|
+
sed -n '/^Fixed upstream, not yet re-checked here/,/tf-feedback.sh <App> --close/p' <<<"$sc" | sed 's/^/tf-phase: /' >&2
|
|
71
105
|
fi
|
|
72
106
|
;;
|
|
73
107
|
goal)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# tf-portable.sh — the few commands that differ between Linux/WSL (GNU tools, bash 4+) and a
|
|
2
|
+
# stock Mac (bash 3.2 at /bin/bash, BSD sed/date/stat, no GNU coreutils). Sourced, not run:
|
|
3
|
+
# source "$(dirname "${BASH_SOURCE[0]}")/tf-portable.sh"
|
|
4
|
+
# Every function uses the native tool when it is there, so Linux and WSL behave exactly as they
|
|
5
|
+
# did before this file existed; the fallback runs only where the native tool is missing.
|
|
6
|
+
# Plain functions only: sourcing it defines functions and nothing else (no variables, no traps,
|
|
7
|
+
# no set options). Bash 3.2 safe. The check tests/portability/run.sh keeps GNU-only and
|
|
8
|
+
# bash-4-only constructs out of every other script; add a function here instead.
|
|
9
|
+
#
|
|
10
|
+
# tf_timeout DURATION CMD [ARG...] timeout(1); 124 when the time runs out
|
|
11
|
+
# tf_setsid [--exec] CMD [ARG...] setsid(1); --exec replaces the calling (sub)shell
|
|
12
|
+
# tf_sed_inplace SED-ARG... FILE sed -i on GNU and BSD sed, no backup file left behind
|
|
13
|
+
# tf_stat_mtime FILE modification time, epoch seconds
|
|
14
|
+
# tf_stat_size FILE size in bytes
|
|
15
|
+
# tf_date_from [-u] WHEN [+FORMAT] date -d WHEN; WHEN is @EPOCH, "-10 min", "+2 hours",
|
|
16
|
+
# "now", or YYYY-MM-DD[( |T)HH:MM[:SS]][Z]
|
|
17
|
+
# tf_epoch_frac seconds since the epoch with a fraction (date +%s.%N)
|
|
18
|
+
# tf_realpath PATH realpath(1) as GNU has it (the last part may be missing)
|
|
19
|
+
# tf_relpath BASE PATH realpath --relative-to=BASE PATH
|
|
20
|
+
# tf_read_lines ARRAY-NAME mapfile -t ARRAY-NAME (reads stdin)
|
|
21
|
+
|
|
22
|
+
# ---- tf_timeout ---------------------------------------------------------------------------
|
|
23
|
+
# GNU timeout, then Homebrew's gtimeout, then a perl fallback (perl ships with macOS). The
|
|
24
|
+
# fallback runs the command in its own process group, sends the group TERM when the time is up
|
|
25
|
+
# and exits 124, the code timeout(1) uses; otherwise it exits with the command's own status
|
|
26
|
+
# (128+N when signal N ended it), and 126/127 when the command cannot be run, as timeout(1) does.
|
|
27
|
+
tf_timeout() {
|
|
28
|
+
if [ $# -lt 2 ]; then echo "tf_timeout: usage: tf_timeout DURATION COMMAND [ARG...]" >&2; return 125; fi
|
|
29
|
+
if command -v timeout >/dev/null 2>&1; then timeout "$@"; return $?; fi
|
|
30
|
+
if command -v gtimeout >/dev/null 2>&1; then gtimeout "$@"; return $?; fi
|
|
31
|
+
_tf_timeout_perl "$@"
|
|
32
|
+
}
|
|
33
|
+
_tf_timeout_perl() {
|
|
34
|
+
local dur="$1" secs; shift
|
|
35
|
+
case "$dur" in
|
|
36
|
+
*s) secs="${dur%s}" ;;
|
|
37
|
+
*m) secs="$(awk -v n="${dur%m}" 'BEGIN { print n * 60 }')" ;;
|
|
38
|
+
*h) secs="$(awk -v n="${dur%h}" 'BEGIN { print n * 3600 }')" ;;
|
|
39
|
+
*d) secs="$(awk -v n="${dur%d}" 'BEGIN { print n * 86400 }')" ;;
|
|
40
|
+
*) secs="$dur" ;;
|
|
41
|
+
esac
|
|
42
|
+
case "$secs" in ''|*[!0-9.]*) echo "tf_timeout: invalid time interval '$dur'" >&2; return 125 ;; esac
|
|
43
|
+
perl -e '
|
|
44
|
+
use strict; use POSIX ();
|
|
45
|
+
my $secs = shift @ARGV;
|
|
46
|
+
my $pid = fork();
|
|
47
|
+
if (!defined $pid) { print STDERR "tf_timeout: fork failed: $!\n"; exit 125; }
|
|
48
|
+
if ($pid == 0) {
|
|
49
|
+
setpgrp(0, 0); # its own process group, so the whole tree is stopped, as timeout(1) does
|
|
50
|
+
exec { $ARGV[0] } @ARGV;
|
|
51
|
+
my $code = $!{ENOENT} ? 127 : 126;
|
|
52
|
+
print STDERR "tf_timeout: failed to run command \x27$ARGV[0]\x27: $!\n";
|
|
53
|
+
POSIX::_exit($code);
|
|
54
|
+
}
|
|
55
|
+
my $fired = 0;
|
|
56
|
+
$SIG{ALRM} = sub { $fired = 1; kill("TERM", -$pid) or kill("TERM", $pid); };
|
|
57
|
+
$SIG{TERM} = sub { kill "TERM", $pid; };
|
|
58
|
+
$SIG{INT} = sub { kill "INT", $pid; };
|
|
59
|
+
if ($secs > 0) {
|
|
60
|
+
my $whole = int($secs); $whole++ if $whole < $secs; # alarm takes whole seconds; round up
|
|
61
|
+
alarm($whole);
|
|
62
|
+
}
|
|
63
|
+
my $got;
|
|
64
|
+
do { $got = waitpid($pid, 0); } while ($got == -1 && $!{EINTR});
|
|
65
|
+
my $status = $?;
|
|
66
|
+
alarm(0);
|
|
67
|
+
exit 124 if $fired;
|
|
68
|
+
exit(128 + ($status & 127)) if $status & 127;
|
|
69
|
+
exit($status >> 8);
|
|
70
|
+
' "$secs" "$@"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# ---- tf_setsid ----------------------------------------------------------------------------
|
|
74
|
+
# util-linux setsid, then python3 os.setsid, then perl POSIX::setsid. As setsid(1) does, a
|
|
75
|
+
# caller that already leads a process group is forked first and the parent returns 0 at once.
|
|
76
|
+
# --exec: replace the calling (sub)shell with it, like `exec setsid CMD`.
|
|
77
|
+
tf_setsid() {
|
|
78
|
+
local ex=""
|
|
79
|
+
if [ "${1:-}" = "--exec" ]; then ex=exec; shift; fi
|
|
80
|
+
if [ $# -lt 1 ]; then echo "tf_setsid: usage: tf_setsid [--exec] COMMAND [ARG...]" >&2; return 1; fi
|
|
81
|
+
if command -v setsid >/dev/null 2>&1; then $ex setsid "$@"
|
|
82
|
+
elif command -v python3 >/dev/null 2>&1; then $ex python3 -c "$(_tf_setsid_py)" "$@"
|
|
83
|
+
elif command -v perl >/dev/null 2>&1; then $ex perl -e "$(_tf_setsid_pl)" "$@"
|
|
84
|
+
else echo "tf_setsid: needs setsid, python3 or perl; none is on PATH" >&2; return 127
|
|
85
|
+
fi
|
|
86
|
+
}
|
|
87
|
+
_tf_setsid_py() {
|
|
88
|
+
printf '%s\n' \
|
|
89
|
+
'import os, sys' \
|
|
90
|
+
'if os.getpgrp() == os.getpid():' \
|
|
91
|
+
' if os.fork() != 0:' \
|
|
92
|
+
' os._exit(0)' \
|
|
93
|
+
'os.setsid()' \
|
|
94
|
+
'try:' \
|
|
95
|
+
' os.execvp(sys.argv[1], sys.argv[1:])' \
|
|
96
|
+
'except OSError as e:' \
|
|
97
|
+
' sys.stderr.write("setsid: failed to execute %s: %s\n" % (sys.argv[1], e.strerror))' \
|
|
98
|
+
' os._exit(127 if e.errno == 2 else 126)'
|
|
99
|
+
}
|
|
100
|
+
_tf_setsid_pl() {
|
|
101
|
+
printf '%s\n' \
|
|
102
|
+
'use POSIX ();' \
|
|
103
|
+
'if (getpgrp() == $$) { my $p = fork(); POSIX::_exit(0) if $p; }' \
|
|
104
|
+
'POSIX::setsid();' \
|
|
105
|
+
'exec { $ARGV[0] } @ARGV;' \
|
|
106
|
+
'my $c = $!{ENOENT} ? 127 : 126;' \
|
|
107
|
+
'print STDERR "setsid: failed to execute $ARGV[0]: $!\n";' \
|
|
108
|
+
'POSIX::_exit($c);'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# ---- tf_sed_inplace -----------------------------------------------------------------------
|
|
112
|
+
# GNU sed takes -i alone; BSD sed needs -i '' for "no backup". The sed script itself must be
|
|
113
|
+
# one both seds understand (no \s, \+, \n in a replacement, 0,/re/ addresses).
|
|
114
|
+
tf_sed_inplace() {
|
|
115
|
+
if [ $# -lt 2 ]; then echo "tf_sed_inplace: usage: tf_sed_inplace SED-ARG... FILE" >&2; return 2; fi
|
|
116
|
+
if sed --version >/dev/null 2>&1; then sed -i "$@"; else sed -i '' "$@"; fi
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# ---- tf_stat_mtime / tf_stat_size ---------------------------------------------------------
|
|
120
|
+
tf_stat_mtime() {
|
|
121
|
+
if stat -c %Y / >/dev/null 2>&1; then stat -c %Y "$1"; else stat -f %m "$1"; fi
|
|
122
|
+
}
|
|
123
|
+
tf_stat_size() {
|
|
124
|
+
if stat -c %s / >/dev/null 2>&1; then stat -c %s "$1"; else stat -f %z "$1"; fi
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# ---- tf_date_from -------------------------------------------------------------------------
|
|
128
|
+
# GNU date reads almost anything with -d; BSD date reads @EPOCH with -r and a fixed layout
|
|
129
|
+
# with -j -f. The WHEN forms listed at the top cover every caller in this framework. On GNU
|
|
130
|
+
# date the WHEN is handed to date -d unchanged, so the output is exactly what it was.
|
|
131
|
+
tf_date_from() {
|
|
132
|
+
local utc="" when fmt epoch
|
|
133
|
+
if [ "${1:-}" = "-u" ]; then utc="-u"; shift; fi
|
|
134
|
+
when="${1:-}"; fmt="${2:-}"
|
|
135
|
+
if [ -z "$when" ]; then echo "tf_date_from: usage: tf_date_from [-u] WHEN [+FORMAT]" >&2; return 1; fi
|
|
136
|
+
if date -d @0 +%s >/dev/null 2>&1; then
|
|
137
|
+
if [ -n "$fmt" ]; then date $utc -d "$when" "$fmt"; else date $utc -d "$when"; fi
|
|
138
|
+
return $?
|
|
139
|
+
fi
|
|
140
|
+
epoch="$(_tf_date_epoch $utc "$when")" || return 1
|
|
141
|
+
if [ -n "$fmt" ]; then date $utc -r "$epoch" "$fmt"; else date $utc -r "$epoch"; fi
|
|
142
|
+
}
|
|
143
|
+
# WHEN → epoch seconds without GNU date -d. @EPOCH, now and relative offsets are plain
|
|
144
|
+
# arithmetic; an absolute date goes through BSD date -j -f (GNU date on a machine that has it).
|
|
145
|
+
_tf_date_epoch() {
|
|
146
|
+
local utc="" when now n unit mult value layout
|
|
147
|
+
if [ "${1:-}" = "-u" ]; then utc="-u"; shift; fi
|
|
148
|
+
when="$1"; now="$(date +%s)"
|
|
149
|
+
case "$when" in
|
|
150
|
+
now) echo "$now"; return 0 ;;
|
|
151
|
+
@*) echo "${when#@}"; return 0 ;;
|
|
152
|
+
esac
|
|
153
|
+
case "$when" in
|
|
154
|
+
[+-][0-9]*|[0-9]*[a-z]*)
|
|
155
|
+
n="$(printf '%s' "$when" | sed -E 's/^([+-]?[0-9]+).*/\1/')"
|
|
156
|
+
unit="$(printf '%s' "$when" | sed -E 's/^[+-]?[0-9]+[[:space:]]*//; s/[[:space:]]*ago$//')"
|
|
157
|
+
case "$unit" in
|
|
158
|
+
s|sec|secs|second|seconds) mult=1 ;;
|
|
159
|
+
min|mins|minute|minutes) mult=60 ;;
|
|
160
|
+
hour|hours) mult=3600 ;;
|
|
161
|
+
day|days) mult=86400 ;;
|
|
162
|
+
week|weeks) mult=604800 ;;
|
|
163
|
+
*) mult="" ;;
|
|
164
|
+
esac
|
|
165
|
+
if [ -n "$mult" ]; then
|
|
166
|
+
n="${n#+}"
|
|
167
|
+
case "$when" in *ago) n=$(( -n )) ;; esac
|
|
168
|
+
echo $(( now + n * mult )); return 0
|
|
169
|
+
fi ;;
|
|
170
|
+
esac
|
|
171
|
+
value="$when"; layout=""
|
|
172
|
+
case "$value" in *Z) value="${value%Z}"; utc="-u" ;; esac
|
|
173
|
+
value="$(printf '%s' "$value" | sed 's/T/ /')"
|
|
174
|
+
case "$value" in
|
|
175
|
+
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) value="$value 00:00:00" ;;
|
|
176
|
+
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]" "[0-9][0-9]:[0-9][0-9]) value="$value:00" ;;
|
|
177
|
+
esac
|
|
178
|
+
case "$value" in
|
|
179
|
+
[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]" "[0-9][0-9]:[0-9][0-9]:[0-9][0-9]) layout="%Y-%m-%d %H:%M:%S" ;;
|
|
180
|
+
esac
|
|
181
|
+
if [ -z "$layout" ]; then echo "date: invalid date '$when'" >&2; return 1; fi
|
|
182
|
+
if date -d @0 +%s >/dev/null 2>&1; then date $utc -d "$value" +%s; else date $utc -j -f "$layout" "$value" +%s; fi
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# ---- tf_epoch_frac ------------------------------------------------------------------------
|
|
186
|
+
# GNU date +%s.%N; BSD date prints a literal N, so the fallback asks perl or python3.
|
|
187
|
+
tf_epoch_frac() {
|
|
188
|
+
local v
|
|
189
|
+
v="$(date +%s.%N 2>/dev/null)"
|
|
190
|
+
case "$v" in *N|'') ;; *) printf '%s\n' "$v"; return 0 ;; esac
|
|
191
|
+
if command -v perl >/dev/null 2>&1; then perl -MTime::HiRes=time -e 'printf("%.9f\n", time)'
|
|
192
|
+
else python3 -c 'import time; print("%.9f" % time.time())'
|
|
193
|
+
fi
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# ---- tf_realpath / tf_relpath -------------------------------------------------------------
|
|
197
|
+
tf_realpath() {
|
|
198
|
+
if [ $# -ne 1 ]; then echo "tf_realpath: usage: tf_realpath PATH" >&2; return 1; fi
|
|
199
|
+
# GNU realpath (it has -m) only: the macOS 13+ realpath refuses a path whose last part does not
|
|
200
|
+
# exist yet, which GNU realpath and the fallback below both accept.
|
|
201
|
+
if realpath -m / >/dev/null 2>&1; then realpath "$1"; return $?; fi
|
|
202
|
+
python3 -c 'import os, sys
|
|
203
|
+
p = sys.argv[1]
|
|
204
|
+
if not os.path.exists(os.path.dirname(os.path.abspath(p)) or "/"):
|
|
205
|
+
sys.stderr.write("realpath: %s: No such file or directory\n" % p); sys.exit(1)
|
|
206
|
+
print(os.path.realpath(p))' "$1"
|
|
207
|
+
}
|
|
208
|
+
tf_relpath() {
|
|
209
|
+
if [ $# -ne 2 ]; then echo "tf_relpath: usage: tf_relpath BASE PATH" >&2; return 1; fi
|
|
210
|
+
if realpath --relative-to=/ / >/dev/null 2>&1; then realpath --relative-to="$1" "$2"; return $?; fi
|
|
211
|
+
python3 -c 'import os, sys
|
|
212
|
+
print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1])))' "$1" "$2"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# ---- tf_read_lines ------------------------------------------------------------------------
|
|
216
|
+
# mapfile -t NAME < <(cmd) becomes tf_read_lines NAME < <(cmd)
|
|
217
|
+
# One element per line, newline removed, a last line without a newline kept, nothing else
|
|
218
|
+
# touched (leading blanks and backslashes stay). NAME is emptied first, as mapfile does.
|
|
219
|
+
tf_read_lines() {
|
|
220
|
+
case "${1:-}" in
|
|
221
|
+
''|[0-9]*|*[!A-Za-z0-9_]*) echo "tf_read_lines: usage: tf_read_lines ARRAY-NAME < input" >&2; return 2 ;;
|
|
222
|
+
esac
|
|
223
|
+
local _tf_rl_line _tf_rl_i=0
|
|
224
|
+
eval "$1=()"
|
|
225
|
+
while IFS= read -r _tf_rl_line || [ -n "$_tf_rl_line" ]; do
|
|
226
|
+
eval "$1[\$_tf_rl_i]=\$_tf_rl_line"
|
|
227
|
+
_tf_rl_i=$(( _tf_rl_i + 1 ))
|
|
228
|
+
done
|
|
229
|
+
return 0
|
|
230
|
+
}
|