ticketwright 2.0.0__py3-none-any.whl
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.
- ticketwright/__init__.py +9 -0
- ticketwright/_kit/.claude/agents/qc-reviewer.md +50 -0
- ticketwright/_kit/.claude/commands/build-context-pack.md +8 -0
- ticketwright/_kit/.claude/commands/build-ticket-index.md +8 -0
- ticketwright/_kit/.claude/commands/configure-workspace.md +8 -0
- ticketwright/_kit/.claude/commands/deliver-ticket.md +8 -0
- ticketwright/_kit/.claude/commands/onboard-teammate.md +8 -0
- ticketwright/_kit/.claude/commands/prime-domain.md +8 -0
- ticketwright/_kit/.claude/commands/prime-ticket.md +8 -0
- ticketwright/_kit/.claude/commands/prime-warehouse.md +8 -0
- ticketwright/_kit/.claude/commands/productize-workflow.md +8 -0
- ticketwright/_kit/.claude/commands/qc-review.md +8 -0
- ticketwright/_kit/.claude/commands/recall.md +8 -0
- ticketwright/_kit/.claude/commands/start-ticket.md +8 -0
- ticketwright/_kit/.claude/config/stack.example.asana-bq.yaml +67 -0
- ticketwright/_kit/.claude/config/stack.example.azure.yaml +73 -0
- ticketwright/_kit/.claude/config/stack.schema.md +76 -0
- ticketwright/_kit/.claude/config/stack.yaml +76 -0
- ticketwright/_kit/.claude/hooks/db_write_guard.py +145 -0
- ticketwright/_kit/.claude/hooks/regenerate_ticket_index.py +74 -0
- ticketwright/_kit/.claude/hooks/session_context.py +78 -0
- ticketwright/_kit/.claude/hooks/ticket_index_context.py +99 -0
- ticketwright/_kit/.claude/settings.json.tmpl +59 -0
- ticketwright/_kit/.claude/skills/productize/SKILL.md +58 -0
- ticketwright/_kit/.claude/skills/productize/authoring.md +36 -0
- ticketwright/_kit/.claude/skills/refresh/SKILL.md +40 -0
- ticketwright/_kit/.claude/skills/refresh/context-pack.md +34 -0
- ticketwright/_kit/.claude/skills/refresh/index.md +45 -0
- ticketwright/_kit/.claude/skills/review/SKILL.md +76 -0
- ticketwright/_kit/.claude/skills/setup/SKILL.md +59 -0
- ticketwright/_kit/.claude/skills/setup/adopt.md +43 -0
- ticketwright/_kit/.claude/skills/setup/scaffold.md +38 -0
- ticketwright/_kit/.claude/skills/setup/teammate.md +33 -0
- ticketwright/_kit/.claude/skills/ship/SKILL.md +53 -0
- ticketwright/_kit/.claude/skills/spec-and-build/SKILL.md +54 -0
- ticketwright/_kit/.claude/skills/ticket/SKILL.md +59 -0
- ticketwright/_kit/.claude/skills/ticket/priming.md +47 -0
- ticketwright/_kit/.claude/statusline.sh +22 -0
- ticketwright/_kit/adapters/README.md +103 -0
- ticketwright/_kit/adapters/chat/slack.md +46 -0
- ticketwright/_kit/adapters/chat/teams.md +38 -0
- ticketwright/_kit/adapters/docstore/gdrive.md +39 -0
- ticketwright/_kit/adapters/docstore/sharepoint.md +41 -0
- ticketwright/_kit/adapters/tracker/asana.md +55 -0
- ticketwright/_kit/adapters/tracker/azure-devops.md +68 -0
- ticketwright/_kit/adapters/tracker/github-issues.md +59 -0
- ticketwright/_kit/adapters/tracker/jira.md +71 -0
- ticketwright/_kit/adapters/tracker/linear.md +56 -0
- ticketwright/_kit/adapters/tracker/monday.md +62 -0
- ticketwright/_kit/adapters/vcs/azure-repos.md +58 -0
- ticketwright/_kit/adapters/vcs/github.md +55 -0
- ticketwright/_kit/adapters/vcs/gitlab.md +52 -0
- ticketwright/_kit/adapters/warehouse/bigquery.md +44 -0
- ticketwright/_kit/adapters/warehouse/databricks.md +58 -0
- ticketwright/_kit/adapters/warehouse/postgres.md +44 -0
- ticketwright/_kit/adapters/warehouse/redshift.md +49 -0
- ticketwright/_kit/adapters/warehouse/snowflake.md +54 -0
- ticketwright/_kit/adapters/warehouse/synapse.md +52 -0
- ticketwright/_kit/bin/build_ticket_index.py +498 -0
- ticketwright/_kit/bin/enrich_ticket.py +138 -0
- ticketwright/_kit/bin/ingest_index_records.py +149 -0
- ticketwright/_kit/bin/recall.py +268 -0
- ticketwright/_kit/bin/render.sh +54 -0
- ticketwright/_kit/bin/render_and_validate.sh +139 -0
- ticketwright/_kit/bin/selftest.sh +408 -0
- ticketwright/_kit/bin/split_and_export.sh +138 -0
- ticketwright/_kit/bin/verify_stack.sh +74 -0
- ticketwright/_kit/templates/AGENTS.md.tmpl +74 -0
- ticketwright/_kit/templates/gitignore.tmpl +35 -0
- ticketwright/_kit/templates/plan.md.tmpl +22 -0
- ticketwright/_kit/templates/productized-skill/SKILL.md.tmpl +67 -0
- ticketwright/_kit/templates/productized-skill/bin/drift_check.sh +30 -0
- ticketwright/_kit/templates/productized-skill/golden/example.json +13 -0
- ticketwright/_kit/templates/productized-skill/sql/qc.sql.tmpl +21 -0
- ticketwright/_kit/templates/productized-skill/sql/step.sql.tmpl +20 -0
- ticketwright/_kit/templates/productized-skill/templates/README.md.tmpl +20 -0
- ticketwright/_kit/templates/productized-skill/templates/tracker_comment.txt.tmpl +14 -0
- ticketwright/_kit/templates/roles/analyst.md +6 -0
- ticketwright/_kit/templates/roles/engineer.md +6 -0
- ticketwright/_kit/templates/roles/generalist.md +3 -0
- ticketwright/_kit/templates/roles/scientist.md +5 -0
- ticketwright/_kit/templates/spec.md.tmpl +34 -0
- ticketwright/_kit/templates/ticket-README.md.tmpl +29 -0
- ticketwright/cli.py +115 -0
- ticketwright-2.0.0.dist-info/METADATA +139 -0
- ticketwright-2.0.0.dist-info/RECORD +89 -0
- ticketwright-2.0.0.dist-info/WHEEL +4 -0
- ticketwright-2.0.0.dist-info/entry_points.txt +2 -0
- ticketwright-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Upsert LLM-extracted records into tickets/index_data.json (the enrichment store).
|
|
3
|
+
|
|
4
|
+
`build_ticket_index.py` renders INDEX.md but never invents summaries — the LLM-authored
|
|
5
|
+
fields (summary / status / best-date / tags / cross_refs) live in tickets/index_data.json.
|
|
6
|
+
This helper takes records produced by the refresh skill (index mode) (or a single hand-written
|
|
7
|
+
record at ticket close), stamps each with the live README's content hash (so the renderer can
|
|
8
|
+
flag stale summaries), upserts by (owner, id), and writes the store.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
ingest_index_records.py --from-json records.json # bulk upsert from {"records":[...]} or [...]
|
|
12
|
+
ingest_index_records.py --from-json - # read JSON from stdin
|
|
13
|
+
|
|
14
|
+
After ingesting, run: python3 bin/build_ticket_index.py
|
|
15
|
+
Stdlib only.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import date
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from build_ticket_index import discover, repo_root, sha256_file, ref_key, load_config, ticket_url
|
|
27
|
+
|
|
28
|
+
VALID_STATUS = {"Completed", "Deployed", "In Review", "In Progress", "Blocked", "Unknown"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _as_list(v) -> list:
|
|
32
|
+
"""Normalize a possibly-string / possibly-missing value into a list of strings."""
|
|
33
|
+
if isinstance(v, str):
|
|
34
|
+
return [v]
|
|
35
|
+
if isinstance(v, (list, tuple)):
|
|
36
|
+
return [x for x in v if isinstance(x, str)]
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --- validators: this is the single trust boundary for LLM-authored records ---
|
|
41
|
+
def _valid_date(v):
|
|
42
|
+
"""Keep an ISO YYYY-MM-DD date that actually parses; drop anything else (e.g. an invented date)."""
|
|
43
|
+
s = (v or "").strip() if isinstance(v, str) else ""
|
|
44
|
+
try:
|
|
45
|
+
return date.fromisoformat(s).isoformat() if s else None
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _clean_objects(v) -> list:
|
|
51
|
+
"""Qualified object names only (must contain a dot) — drops bare prose like 'the loan view'."""
|
|
52
|
+
seen: dict[str, str] = {}
|
|
53
|
+
for o in _as_list(v):
|
|
54
|
+
o = o.strip()
|
|
55
|
+
if "." in o:
|
|
56
|
+
seen.setdefault(o.lower(), o)
|
|
57
|
+
return sorted(seen.values(), key=str.lower)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clean_tags(v, cap: int = 6) -> list:
|
|
61
|
+
"""Coerce to kebab-case, dedup (order-preserving), cap the count."""
|
|
62
|
+
out = []
|
|
63
|
+
for t in _as_list(v):
|
|
64
|
+
k = re.sub(r"[^a-z0-9]+", "-", t.strip().lower()).strip("-")
|
|
65
|
+
if k and k not in out:
|
|
66
|
+
out.append(k)
|
|
67
|
+
return out[:cap]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_records(spec: str) -> list[dict]:
|
|
71
|
+
text = sys.stdin.read() if spec == "-" else Path(spec).read_text()
|
|
72
|
+
try:
|
|
73
|
+
data = json.loads(text)
|
|
74
|
+
except json.JSONDecodeError as e:
|
|
75
|
+
sys.exit(f"ERROR: --from-json input is not valid JSON ({e}).")
|
|
76
|
+
if isinstance(data, dict):
|
|
77
|
+
data = data.get("records", [])
|
|
78
|
+
if not isinstance(data, list) or not all(isinstance(r, dict) for r in data):
|
|
79
|
+
sys.exit('ERROR: records must be a JSON array of objects (or {"records": [...]}).')
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main() -> int:
|
|
84
|
+
ap = argparse.ArgumentParser(description="Upsert records into tickets/index_data.json")
|
|
85
|
+
ap.add_argument("--from-json", required=True, help="path to records JSON, or '-' for stdin")
|
|
86
|
+
args = ap.parse_args()
|
|
87
|
+
|
|
88
|
+
root = repo_root()
|
|
89
|
+
dirs = {(t["owner"], t["id"]): t for t in discover(root)}
|
|
90
|
+
url_template = load_config(root)["url_template"]
|
|
91
|
+
|
|
92
|
+
store_path = root / "tickets" / "index_data.json"
|
|
93
|
+
store: dict[tuple[str, str], dict] = {}
|
|
94
|
+
if store_path.is_file():
|
|
95
|
+
try:
|
|
96
|
+
sdata = json.loads(store_path.read_text())
|
|
97
|
+
except json.JSONDecodeError as e:
|
|
98
|
+
sys.exit(f"ERROR: existing tickets/index_data.json is malformed ({e}); refusing to overwrite.")
|
|
99
|
+
if not isinstance(sdata, dict) or not isinstance(sdata.get("tickets"), list):
|
|
100
|
+
sys.exit("ERROR: existing tickets/index_data.json must be an object with a 'tickets' list.")
|
|
101
|
+
store = {(t["owner"], t["id"]): t for t in sdata["tickets"]
|
|
102
|
+
if isinstance(t, dict) and isinstance(t.get("owner"), str) and isinstance(t.get("id"), str)}
|
|
103
|
+
|
|
104
|
+
incoming = load_records(args.from_json)
|
|
105
|
+
upserted, skipped = 0, []
|
|
106
|
+
for r in incoming:
|
|
107
|
+
owner, tid = r.get("owner"), r.get("id")
|
|
108
|
+
loc = dirs.get((owner, tid))
|
|
109
|
+
if not loc:
|
|
110
|
+
skipped.append(f"{owner or '?'}/{tid or '?'}")
|
|
111
|
+
continue
|
|
112
|
+
owner, tid = str(owner), str(tid)
|
|
113
|
+
readme = loc["readme"]
|
|
114
|
+
status = r.get("status") if r.get("status") in VALID_STATUS else "Unknown"
|
|
115
|
+
refs = sorted({c for c in _as_list(r.get("cross_refs")) if c != tid}, key=ref_key)
|
|
116
|
+
rec = {
|
|
117
|
+
"id": tid,
|
|
118
|
+
"owner": owner,
|
|
119
|
+
"title": (r.get("title") or "").strip(),
|
|
120
|
+
"status": status,
|
|
121
|
+
"date": _valid_date(r.get("date")),
|
|
122
|
+
"cross_refs": refs,
|
|
123
|
+
"tags": _clean_tags(r.get("tags")),
|
|
124
|
+
"objects": _clean_objects(r.get("objects")),
|
|
125
|
+
"summary": (r.get("summary") or "").strip(),
|
|
126
|
+
"confidence": r.get("confidence") or "medium",
|
|
127
|
+
"readme_present": bool(readme),
|
|
128
|
+
"readme_hash": sha256_file(readme) if readme else None,
|
|
129
|
+
}
|
|
130
|
+
url = r.get("ticket_url") or r.get("jira_url") or ticket_url(url_template, tid)
|
|
131
|
+
if url:
|
|
132
|
+
rec["ticket_url"] = url
|
|
133
|
+
store[(owner, tid)] = rec
|
|
134
|
+
upserted += 1
|
|
135
|
+
|
|
136
|
+
tickets = sorted(store.values(),
|
|
137
|
+
key=lambda t: ((t.get("date") or "0000-00-00"), ref_key(t["id"]), t["owner"]),
|
|
138
|
+
reverse=True)
|
|
139
|
+
payload = json.dumps({"schema_version": 1, "tickets": tickets}, indent=2, ensure_ascii=False) + "\n"
|
|
140
|
+
store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
store_path.write_bytes(payload.encode("utf-8"))
|
|
142
|
+
print(f"Upserted {upserted} record(s); store now has {len(tickets)}.", file=sys.stderr)
|
|
143
|
+
if skipped:
|
|
144
|
+
print(f"Skipped {len(skipped)} (no matching folder): {', '.join(skipped)}", file=sys.stderr)
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prior-art recall — rank prior tickets most relevant to a new one, for reuse in the PLAN phase.
|
|
3
|
+
|
|
4
|
+
Deterministic, lexical, stdlib-only (NO embeddings/vector DB — see the kit's KISS stance). Scores
|
|
5
|
+
every ticket (from tickets/index_data.json, falling back to README-derived fields for un-enriched
|
|
6
|
+
ones) against a query built from a seed ticket and/or free text / tags / object, with a transparent
|
|
7
|
+
score breakdown. The `/recall` command runs this, then reads the top hits and writes a reuse brief.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
recall.py --for ENG-12 [--top 5] [--min-score N] [--json] # query = that ticket's fields
|
|
11
|
+
recall.py --query "genesys call metrics" [--tags a,b] [--object SCHEMA.VW] [--top 5] [--json]
|
|
12
|
+
recall.py --object BI.ANALYTICS.VW_LOAN # reverse lookup: tickets that touched an object
|
|
13
|
+
recall.py --eval [--sweep] # diagnostic: recall quality vs curated cross_refs
|
|
14
|
+
|
|
15
|
+
Scoring (transparent): object match ×4 (IDF-discounted — a ubiquitous object counts less than a rare
|
|
16
|
+
shared one), tag match ×3, cross-ref link +5, keyword overlap ×1 (capped). Recency is a tiebreak only.
|
|
17
|
+
Stdlib only.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import math
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
from build_ticket_index import repo_root, build_rows, ticket_number
|
|
28
|
+
|
|
29
|
+
STOPWORDS = {
|
|
30
|
+
"the", "a", "an", "and", "or", "for", "of", "to", "in", "on", "by", "with", "from", "into",
|
|
31
|
+
"is", "are", "be", "as", "at", "this", "that", "it", "via", "per", "vs", "new", "add", "update",
|
|
32
|
+
"fix", "ticket", "data", "report", "list", "file", "files", "table", "view",
|
|
33
|
+
}
|
|
34
|
+
W_OBJECT, W_TAG, W_REF, W_KEYWORD, KEYWORD_CAP = 4, 3, 5, 1, 6
|
|
35
|
+
OBJ_IDF_FLOOR = 0.4 # ubiquitous object still counts, but < a rare shared one. 0.4 tuned via --eval:
|
|
36
|
+
# strict Pareto gain on a 139-ticket corpus (MRR .550->.571, P@1 .408->.421, P@3 .618->.671, rec@5 .462->.494)
|
|
37
|
+
DEFAULT_WEIGHTS = {"obj": W_OBJECT, "tag": W_TAG, "ref": W_REF, "kw": W_KEYWORD}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def tokenize(text: str) -> set[str]:
|
|
41
|
+
return {t for t in re.split(r"[^a-z0-9]+", (text or "").lower())
|
|
42
|
+
if len(t) >= 3 and t not in STOPWORDS}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ci(values) -> set[str]:
|
|
46
|
+
return {str(v).strip().lower() for v in (values or []) if str(v).strip()}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def leaf(name: str) -> str:
|
|
50
|
+
"""Trailing object name, stripped of schema/db qualification: `bi.analytics.vw_loan` -> `vw_loan`."""
|
|
51
|
+
return name.rsplit(".", 1)[-1]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def object_hits(q_objects: set[str], r_objects: set[str]) -> list[str]:
|
|
55
|
+
"""Row objects that match a query object, leaf-aware so `vw_loan` finds `bi.analytics.vw_loan`."""
|
|
56
|
+
q_leaves = {leaf(q) for q in q_objects}
|
|
57
|
+
return sorted(o for o in r_objects if o in q_objects or leaf(o) in q_leaves)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def object_df(rows) -> dict:
|
|
61
|
+
"""Leaf-granularity document frequency: how many tickets reference each object leaf."""
|
|
62
|
+
df: dict[str, int] = {}
|
|
63
|
+
for r in rows:
|
|
64
|
+
for lf in {leaf(o) for o in ci(r.get("objects"))}:
|
|
65
|
+
df[lf] = df.get(lf, 0) + 1
|
|
66
|
+
return df
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def idf_factor(df_map: dict, n_docs: int, name: str) -> float:
|
|
70
|
+
"""Discount in (FLOOR, 1.0]: the rarest object -> 1.0, a ubiquitous one -> ~FLOOR. Never a bonus,
|
|
71
|
+
so flat W_OBJECT stays the ceiling. Honors the kit's transparent-weights stance — no learned vector."""
|
|
72
|
+
if not df_map or n_docs <= 0:
|
|
73
|
+
return 1.0
|
|
74
|
+
denom = math.log(n_docs / (1 + min(df_map.values())))
|
|
75
|
+
if denom <= 0:
|
|
76
|
+
return 1.0
|
|
77
|
+
f = math.log(n_docs / (1 + df_map.get(leaf(name), 1))) / denom
|
|
78
|
+
return max(OBJ_IDF_FLOOR, min(1.0, f))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def score_candidate(q_tokens, q_tags, q_objects, seed_id, seed_refs, r, weights, allow_ref, df_map, n_docs):
|
|
82
|
+
"""One candidate's score + breakdown. `allow_ref=False` disables the cross-ref signal (used by --eval,
|
|
83
|
+
where cross_refs are the ground-truth labels and must not leak into scoring)."""
|
|
84
|
+
r_tags, r_objects = ci(r.get("tags")), ci(r.get("objects"))
|
|
85
|
+
r_tokens = tokenize(f"{r['title']} {r['summary']}") # tags score via tag_hits only, not also as keywords
|
|
86
|
+
obj_hits = object_hits(q_objects, r_objects)
|
|
87
|
+
tag_hits = sorted(q_tags & r_tags)
|
|
88
|
+
kw_hits = q_tokens & r_tokens
|
|
89
|
+
ref_link = bool(allow_ref and seed_id and (r["id"].lower() in seed_refs or seed_id.lower() in ci(r.get("cross_refs"))))
|
|
90
|
+
obj_score = sum(weights["obj"] * idf_factor(df_map, n_docs, o) for o in obj_hits)
|
|
91
|
+
score = (obj_score + weights["tag"] * len(tag_hits)
|
|
92
|
+
+ weights["ref"] * (1 if ref_link else 0) + weights["kw"] * min(len(kw_hits), KEYWORD_CAP))
|
|
93
|
+
return score, obj_hits, tag_hits, kw_hits, ref_link
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sort_key(item):
|
|
97
|
+
# self-contained total order: score, then date, ticket number, id, owner — all desc.
|
|
98
|
+
s, r, _why = item
|
|
99
|
+
return (s, r["date"] or "0000-00-00", ticket_number(r["id"]), r["id"], r["owner"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def verdict_line(top) -> str:
|
|
103
|
+
"""Advisory one-liner so PLAN can decide whether to read candidates. Scale-free (composition + gap),
|
|
104
|
+
never an auto-skip."""
|
|
105
|
+
if not top:
|
|
106
|
+
return "verdict: no prior art found — likely greenfield."
|
|
107
|
+
s0, _r0, why0 = top[0]
|
|
108
|
+
s1 = top[1][0] if len(top) > 1 else 0.0
|
|
109
|
+
strong = any(w.startswith("obj:") or w == "ref" for w in why0)
|
|
110
|
+
kw_only = bool(why0) and all(w.startswith("kw:") for w in why0)
|
|
111
|
+
if strong:
|
|
112
|
+
v = "strong — top hit shares an object or cross-ref"
|
|
113
|
+
elif s1 and s0 >= 2 * s1:
|
|
114
|
+
v = "clear leader — top score dominates the runner-up"
|
|
115
|
+
elif kw_only:
|
|
116
|
+
v = "weak — top hit is keyword-only; may be greenfield"
|
|
117
|
+
else:
|
|
118
|
+
v = "moderate"
|
|
119
|
+
return f"verdict: {v}. (You may skip reading candidates when weak/none.)"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def run_query(args, rows, df_map, n_docs) -> int:
|
|
123
|
+
q_tokens, q_tags, q_objects = tokenize(args.query), ci(args.tags.split(",")), ci([args.object] if args.object else [])
|
|
124
|
+
seed_id, seed_owner, seed_refs = None, None, set()
|
|
125
|
+
if args.seed:
|
|
126
|
+
sid, sown = args.seed.lower(), (args.owner or "").lower() # case-insensitive id/owner match
|
|
127
|
+
seed_matches = [r for r in rows if r["id"].lower() == sid and (not sown or r["owner"].lower() == sown)]
|
|
128
|
+
if not seed_matches:
|
|
129
|
+
sys.exit(f"recall: seed ticket {args.seed} not found (try --owner to disambiguate).")
|
|
130
|
+
if len(seed_matches) > 1: # same id under >1 owner and no --owner — don't silently pick one
|
|
131
|
+
owners = ", ".join(sorted(r["owner"] for r in seed_matches))
|
|
132
|
+
sys.exit(f"recall: {args.seed} exists under multiple owners ({owners}); pass --owner to pick one.")
|
|
133
|
+
seed = seed_matches[0]
|
|
134
|
+
seed_id, seed_owner = seed["id"], seed["owner"]
|
|
135
|
+
seed_refs = ci(seed.get("cross_refs"))
|
|
136
|
+
q_tokens |= tokenize(f"{seed['title']} {seed['summary']}")
|
|
137
|
+
q_tags |= ci(seed.get("tags"))
|
|
138
|
+
q_objects |= ci(seed.get("objects"))
|
|
139
|
+
|
|
140
|
+
reverse_only = bool(args.object) and not (args.seed or args.query or args.tags)
|
|
141
|
+
|
|
142
|
+
scored = []
|
|
143
|
+
for r in rows:
|
|
144
|
+
if seed_id and r["id"] == seed_id and r["owner"] == seed_owner:
|
|
145
|
+
continue # exclude only the chosen seed row (same id under another owner is a real candidate)
|
|
146
|
+
score, obj_hits, tag_hits, kw_hits, ref_link = score_candidate(
|
|
147
|
+
q_tokens, q_tags, q_objects, seed_id, seed_refs, r, DEFAULT_WEIGHTS, allow_ref=True,
|
|
148
|
+
df_map=df_map, n_docs=n_docs)
|
|
149
|
+
if reverse_only:
|
|
150
|
+
if not obj_hits:
|
|
151
|
+
continue
|
|
152
|
+
score = float(W_OBJECT * len(obj_hits)) # reverse lookup is flat count — IDF would be constant here
|
|
153
|
+
elif score <= 0:
|
|
154
|
+
continue
|
|
155
|
+
why = []
|
|
156
|
+
if obj_hits: why.append("obj:" + ",".join(f"{o}~{idf_factor(df_map, n_docs, o):.2f}" for o in obj_hits))
|
|
157
|
+
if tag_hits: why.append("tag:" + ",".join(tag_hits))
|
|
158
|
+
if ref_link: why.append("ref")
|
|
159
|
+
if kw_hits and not reverse_only: why.append(f"kw:{min(len(kw_hits), KEYWORD_CAP)}")
|
|
160
|
+
scored.append((score, r, why))
|
|
161
|
+
|
|
162
|
+
scored.sort(key=sort_key, reverse=True)
|
|
163
|
+
if args.min_score:
|
|
164
|
+
scored = [x for x in scored if x[0] >= args.min_score]
|
|
165
|
+
top = scored[: args.top]
|
|
166
|
+
|
|
167
|
+
if args.json:
|
|
168
|
+
print(json.dumps([
|
|
169
|
+
{"id": r["id"], "owner": r["owner"], "score": round(s, 2), "why": why, "title": r["title"],
|
|
170
|
+
"summary": r["summary"], "date": r["date"], "tags": r.get("tags"), "objects": r.get("objects"),
|
|
171
|
+
"readme": r["link"], "url": r["url"]}
|
|
172
|
+
for s, r, why in top], ensure_ascii=False, indent=2))
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
header = (f"Reverse lookup: tickets touching {args.object}" if reverse_only
|
|
176
|
+
else "Prior art" + (f" for {seed_id}" if seed_id else "")) + f" — top {len(top)} of {len(scored)}"
|
|
177
|
+
print(header)
|
|
178
|
+
if not top:
|
|
179
|
+
print(" (no matches)")
|
|
180
|
+
if not reverse_only:
|
|
181
|
+
print(verdict_line(top))
|
|
182
|
+
return 0
|
|
183
|
+
for s, r, why in top:
|
|
184
|
+
print(f" {r['id']:<10} score {s:>5.1f} [{' '.join(why)}] {r['date'] or '—'} {r['title'][:60]}")
|
|
185
|
+
print(f" {r['link']}")
|
|
186
|
+
if not reverse_only:
|
|
187
|
+
print(verdict_line(top))
|
|
188
|
+
print("\nRead these READMEs and write a reuse brief: what to copy (which SQL/QC artifact + path),"
|
|
189
|
+
" known gotchas, and what's different this time.")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run_eval(rows, df_map, n_docs, weights, top_k=5) -> dict:
|
|
194
|
+
"""Hold out each ticket's curated cross_refs and measure whether recall predicts them. The cross-ref
|
|
195
|
+
signal is DISABLED (allow_ref=False) so the labels can't leak into scoring."""
|
|
196
|
+
ids_present = {r["id"].lower() for r in rows}
|
|
197
|
+
rr_sum = p1 = p3 = rec_sum = labeled = 0
|
|
198
|
+
for seed in rows:
|
|
199
|
+
relevant = {x for x in ci(seed.get("cross_refs")) if x in ids_present and x != seed["id"].lower()}
|
|
200
|
+
if not relevant:
|
|
201
|
+
continue
|
|
202
|
+
labeled += 1
|
|
203
|
+
q_tokens = tokenize(f"{seed['title']} {seed['summary']}")
|
|
204
|
+
q_tags, q_objects = ci(seed.get("tags")), ci(seed.get("objects"))
|
|
205
|
+
ranked = []
|
|
206
|
+
for r in rows:
|
|
207
|
+
if r["id"] == seed["id"] and r["owner"] == seed["owner"]:
|
|
208
|
+
continue
|
|
209
|
+
score, *_ = score_candidate(q_tokens, q_tags, q_objects, None, set(), r, weights,
|
|
210
|
+
allow_ref=False, df_map=df_map, n_docs=n_docs)
|
|
211
|
+
ranked.append((score, r, []))
|
|
212
|
+
ranked.sort(key=sort_key, reverse=True)
|
|
213
|
+
ordered = [r["id"].lower() for _s, r, _w in ranked]
|
|
214
|
+
first = next((i + 1 for i, x in enumerate(ordered) if x in relevant), None)
|
|
215
|
+
if first:
|
|
216
|
+
rr_sum += 1.0 / first
|
|
217
|
+
top_ids = ordered[:top_k]
|
|
218
|
+
if top_ids and top_ids[0] in relevant:
|
|
219
|
+
p1 += 1
|
|
220
|
+
if any(x in relevant for x in top_ids[:3]):
|
|
221
|
+
p3 += 1
|
|
222
|
+
rec_sum += len({x for x in top_ids if x in relevant}) / len(relevant)
|
|
223
|
+
if not labeled:
|
|
224
|
+
return {"labeled": 0}
|
|
225
|
+
return {"labeled": labeled, "mrr": rr_sum / labeled, "p_at_1": p1 / labeled,
|
|
226
|
+
"p_at_3": p3 / labeled, "recall_at_5": rec_sum / labeled}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main() -> int:
|
|
230
|
+
ap = argparse.ArgumentParser(description="Rank prior tickets relevant to a new one (prior-art recall)")
|
|
231
|
+
ap.add_argument("--for", dest="seed", help="seed ticket id (use its title/summary/tags/objects as the query)")
|
|
232
|
+
ap.add_argument("--owner", help="disambiguate the seed when an id exists under multiple owners")
|
|
233
|
+
ap.add_argument("--query", default="", help="free-text query")
|
|
234
|
+
ap.add_argument("--tags", default="", help="comma-separated tags to match")
|
|
235
|
+
ap.add_argument("--object", dest="object", default="", help="object name (also enables reverse lookup)")
|
|
236
|
+
ap.add_argument("--top", type=int, default=5)
|
|
237
|
+
ap.add_argument("--min-score", dest="min_score", type=float, default=0.0,
|
|
238
|
+
help="hide candidates scoring below this (manual filter; not a default cutoff)")
|
|
239
|
+
ap.add_argument("--json", action="store_true")
|
|
240
|
+
ap.add_argument("--eval", action="store_true",
|
|
241
|
+
help="diagnostic: measure recall quality against curated cross_refs (read-only, never tunes)")
|
|
242
|
+
ap.add_argument("--sweep", action="store_true", help="with --eval: print MRR under several weight settings")
|
|
243
|
+
args = ap.parse_args()
|
|
244
|
+
|
|
245
|
+
rows = build_rows(repo_root())
|
|
246
|
+
df_map, n_docs = object_df(rows), len(rows)
|
|
247
|
+
|
|
248
|
+
if args.eval:
|
|
249
|
+
base = run_eval(rows, df_map, n_docs, DEFAULT_WEIGHTS)
|
|
250
|
+
if not base.get("labeled"):
|
|
251
|
+
print("recall --eval: no tickets have cross_refs resolving in-corpus — nothing to score.")
|
|
252
|
+
return 0
|
|
253
|
+
print(f"Recall quality vs curated cross_refs (cross-ref signal held out; {base['labeled']} labeled seeds):")
|
|
254
|
+
print(f" MRR={base['mrr']:.3f} P@1={base['p_at_1']:.3f} P@3={base['p_at_3']:.3f} recall@5={base['recall_at_5']:.3f}")
|
|
255
|
+
if args.sweep:
|
|
256
|
+
print("Weight sensitivity (obj,tag,ref,kw) — diagnostic only, NOT auto-applied:")
|
|
257
|
+
for w in ({"obj": 4, "tag": 3, "ref": 5, "kw": 1}, {"obj": 4, "tag": 1, "ref": 5, "kw": 1},
|
|
258
|
+
{"obj": 4, "tag": 5, "ref": 5, "kw": 1}, {"obj": 4, "tag": 3, "ref": 5, "kw": 2},
|
|
259
|
+
{"obj": 0, "tag": 3, "ref": 5, "kw": 1}, {"obj": 4, "tag": 0, "ref": 5, "kw": 0}):
|
|
260
|
+
m = run_eval(rows, df_map, n_docs, w)
|
|
261
|
+
print(f" ({w['obj']},{w['tag']},{w['ref']},{w['kw']}) -> MRR {m['mrr']:.3f}")
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
return run_query(args, rows, df_map, n_docs)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# render.sh — tiny {{token}} renderer. No deps beyond coreutils + perl (preinstalled on macOS/Linux).
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# bin/render.sh TEMPLATE KEY=VALUE [KEY2=VALUE2 ...] # vars on the command line
|
|
6
|
+
# bin/render.sh TEMPLATE --vars FILE # vars from a KEY=VALUE file
|
|
7
|
+
# bin/render.sh TEMPLATE ... --strict # exit 2 if any {{token}} is left unresolved
|
|
8
|
+
#
|
|
9
|
+
# Writes the rendered text to stdout. Unresolved {{tokens}} are always reported to stderr.
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
|
|
12
|
+
if [[ $# -lt 1 ]]; then
|
|
13
|
+
echo "usage: $0 TEMPLATE [KEY=VALUE ... | --vars FILE] [--strict]" >&2
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
template="$1"; shift
|
|
18
|
+
[[ -f "$template" ]] || { echo "render: template not found: $template" >&2; exit 1; }
|
|
19
|
+
|
|
20
|
+
strict=0
|
|
21
|
+
declare -a pairs=()
|
|
22
|
+
while [[ $# -gt 0 ]]; do
|
|
23
|
+
case "$1" in
|
|
24
|
+
--strict) strict=1; shift ;;
|
|
25
|
+
--vars) shift; [[ -f "${1:-}" ]] || { echo "render: vars file not found: ${1:-}" >&2; exit 1; }
|
|
26
|
+
while IFS= read -r line; do
|
|
27
|
+
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
|
28
|
+
[[ "$line" == *"="* ]] && pairs+=("$line")
|
|
29
|
+
done < "$1"; shift ;;
|
|
30
|
+
*=*) pairs+=("$1"); shift ;;
|
|
31
|
+
*) echo "render: unrecognized arg: $1" >&2; exit 1 ;;
|
|
32
|
+
esac
|
|
33
|
+
done
|
|
34
|
+
|
|
35
|
+
content="$(cat "$template")"
|
|
36
|
+
# Guard the empty-array case: on bash 3.2 under `set -u`, "${pairs[@]}" with zero elements aborts.
|
|
37
|
+
if [[ ${#pairs[@]} -gt 0 ]]; then
|
|
38
|
+
for pair in "${pairs[@]}"; do
|
|
39
|
+
key="${pair%%=*}"
|
|
40
|
+
val="${pair#*=}"
|
|
41
|
+
# Replace every {{key}} with val. perl handles arbitrary value chars safely via env passing.
|
|
42
|
+
content="$(KEY="$key" VAL="$val" perl -pe 's/\{\{\Q$ENV{KEY}\E\}\}/$ENV{VAL}/g' <<< "$content")"
|
|
43
|
+
done
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
printf '%s\n' "$content"
|
|
47
|
+
|
|
48
|
+
# Report anything still unresolved.
|
|
49
|
+
leftover="$(grep -oE '\{\{[^}]+\}\}' <<< "$content" | sort -u || true)"
|
|
50
|
+
if [[ -n "$leftover" ]]; then
|
|
51
|
+
echo "render: WARNING unresolved tokens:" >&2
|
|
52
|
+
echo "$leftover" >&2
|
|
53
|
+
if [[ "$strict" -eq 1 ]]; then exit 2; fi # non-strict: warn only, exit 0 (don't fail callers)
|
|
54
|
+
fi
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# render_and_validate.sh — render a SQL template AND gate the result. Wraps bin/render.sh so the
|
|
3
|
+
# token substitution stays single-sourced; adds the two authoring rules that bit a productized pull
|
|
4
|
+
# in the wild, plus a cheap structural check on the rendered SQL:
|
|
5
|
+
#
|
|
6
|
+
# 1. No {{token}} inside a SQL comment (ERROR). The renderer expands tokens everywhere — including
|
|
7
|
+
# inside a `--` comment. A multi-line value (e.g. a 75-row VALUES list) then spills past the
|
|
8
|
+
# `--`; the continuation rows are bare SQL → compile error. Describe params in prose, not tokens.
|
|
9
|
+
# 2. A token used as a SQL string/date literal must be QUOTED in the template (WARN; ERROR under
|
|
10
|
+
# --strict). `SET d = {{asof}};` renders to `= 2026-06-30`, which the warehouse reads as
|
|
11
|
+
# arithmetic (=1990), not a date — silent wrong results. Write `'{{asof}}'` in the template.
|
|
12
|
+
#
|
|
13
|
+
# Then it validates the rendered SQL: zero {{tokens}} remain, balanced single-quotes, balanced parens.
|
|
14
|
+
#
|
|
15
|
+
# Usage (same surface as render.sh):
|
|
16
|
+
# bin/render_and_validate.sh STEP.sql.tmpl KEY=VALUE [KEY2=VALUE2 ...] [--strict]
|
|
17
|
+
# bin/render_and_validate.sh STEP.sql.tmpl --vars FILE [--strict]
|
|
18
|
+
#
|
|
19
|
+
# Rendered SQL → stdout. Findings → stderr. Exit 1 on any ERROR (WARN also fails under --strict).
|
|
20
|
+
# This is for SQL step/QC templates; use plain render.sh for prose templates (README, tracker comment).
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
here="$(cd "$(dirname "$0")" && pwd)"
|
|
24
|
+
render="$here/render.sh"
|
|
25
|
+
|
|
26
|
+
if [[ $# -lt 1 ]]; then
|
|
27
|
+
echo "usage: $0 TEMPLATE [KEY=VALUE ... | --vars FILE] [--strict]" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
template="$1"; shift
|
|
31
|
+
[[ -f "$template" ]] || { echo "render_and_validate: template not found: $template" >&2; exit 1; }
|
|
32
|
+
[[ -f "$render" ]] || { echo "render_and_validate: render.sh not found beside this script" >&2; exit 1; }
|
|
33
|
+
|
|
34
|
+
strict=0
|
|
35
|
+
declare -a fwd=() # everything except --strict is forwarded to render.sh verbatim
|
|
36
|
+
while [[ $# -gt 0 ]]; do
|
|
37
|
+
case "$1" in
|
|
38
|
+
--strict) strict=1; shift ;;
|
|
39
|
+
*) fwd+=("$1"); shift ;;
|
|
40
|
+
esac
|
|
41
|
+
done
|
|
42
|
+
|
|
43
|
+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
44
|
+
# Substitute via the canonical renderer (non-strict: the validator below reports leftover tokens
|
|
45
|
+
# with full context). Guard the empty-array case for bash 3.2 under `set -u`.
|
|
46
|
+
if [[ ${#fwd[@]} -gt 0 ]]; then
|
|
47
|
+
bash "$render" "$template" "${fwd[@]}" > "$TMP/rendered" 2>/dev/null
|
|
48
|
+
else
|
|
49
|
+
bash "$render" "$template" > "$TMP/rendered" 2>/dev/null
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
python3 - "$template" "$TMP/rendered" "$strict" <<'PY'
|
|
53
|
+
import re, sys
|
|
54
|
+
template_path, rendered_path, strict = sys.argv[1], sys.argv[2], sys.argv[3] == "1"
|
|
55
|
+
errs, warns = [], []
|
|
56
|
+
TOKEN = re.compile(r"\{\{[^{}]+\}\}")
|
|
57
|
+
LITERAL = re.compile(r"(<=|>=|<>|!=|=|<|>)[ \t]*\{\{\s*[\w.-]+\s*\}\}")
|
|
58
|
+
|
|
59
|
+
def classify(text):
|
|
60
|
+
"""Tag each char: 'c' code, 's' single-quoted string (''-escapes honored), 'm' comment
|
|
61
|
+
(-- to EOL and /* */ blocks). Returns (tags, ended_inside_string)."""
|
|
62
|
+
tags = []
|
|
63
|
+
in_str = in_line = in_block = False
|
|
64
|
+
i, n = 0, len(text)
|
|
65
|
+
while i < n:
|
|
66
|
+
c, two = text[i], text[i:i+2]
|
|
67
|
+
if in_line:
|
|
68
|
+
tags.append('m')
|
|
69
|
+
if c == '\n':
|
|
70
|
+
in_line = False
|
|
71
|
+
i += 1; continue
|
|
72
|
+
if in_block:
|
|
73
|
+
if two == '*/':
|
|
74
|
+
tags += ['m', 'm']; i += 2; in_block = False; continue
|
|
75
|
+
tags.append('m'); i += 1; continue
|
|
76
|
+
if in_str:
|
|
77
|
+
if c == "'":
|
|
78
|
+
if text[i+1:i+2] == "'": # '' is an escaped quote, stay in string
|
|
79
|
+
tags += ['s', 's']; i += 2; continue
|
|
80
|
+
tags.append('s'); in_str = False; i += 1; continue
|
|
81
|
+
tags.append('s'); i += 1; continue
|
|
82
|
+
if two == '--':
|
|
83
|
+
tags += ['m', 'm']; i += 2; in_line = True; continue
|
|
84
|
+
if two == '/*':
|
|
85
|
+
tags += ['m', 'm']; i += 2; in_block = True; continue
|
|
86
|
+
if c == "'":
|
|
87
|
+
tags.append('s'); in_str = True; i += 1; continue
|
|
88
|
+
tags.append('c'); i += 1; continue
|
|
89
|
+
return ''.join(tags), in_str
|
|
90
|
+
|
|
91
|
+
# ---- pre-render lint on the TEMPLATE ----
|
|
92
|
+
tpl = open(template_path, encoding="utf-8").read()
|
|
93
|
+
ttags, _ = classify(tpl)
|
|
94
|
+
code_only = ''.join(ch if ttags[i] == 'c' else ' ' for i, ch in enumerate(tpl))
|
|
95
|
+
lineno = lambda pos: tpl.count('\n', 0, pos) + 1
|
|
96
|
+
for m in TOKEN.finditer(tpl):
|
|
97
|
+
if all(ttags[j] == 'm' for j in range(m.start(), m.end())): # rule 1: token in a comment
|
|
98
|
+
ln = lineno(m.start())
|
|
99
|
+
errs.append(f"L{ln}: {m.group(0)} inside a SQL comment — describe the param in prose; the "
|
|
100
|
+
f"renderer expands it and a multi-line value breaks out of the `--`.")
|
|
101
|
+
for m in LITERAL.finditer(code_only): # rule 2: unquoted SQL literal
|
|
102
|
+
ln = lineno(m.start())
|
|
103
|
+
warns.append(f"L{ln}: '{m.group(0).strip()}' — token used as an unquoted SQL literal; quote it "
|
|
104
|
+
f"in the template ('{{{{token}}}}') if it is a date/string, else it reads as arithmetic.")
|
|
105
|
+
|
|
106
|
+
# ---- post-render validation on the RENDERED output ----
|
|
107
|
+
out = open(rendered_path, encoding="utf-8").read()
|
|
108
|
+
left = sorted(set(t.group(0) for t in TOKEN.finditer(out)))
|
|
109
|
+
if left:
|
|
110
|
+
errs.append("unresolved token(s) remain after render: " + " ".join(left))
|
|
111
|
+
otags, open_str = classify(out)
|
|
112
|
+
if open_str:
|
|
113
|
+
errs.append("unbalanced single-quote in rendered SQL (a string literal never closes) — likely an "
|
|
114
|
+
"unquoted token or a stray quote.")
|
|
115
|
+
depth = lo = 0
|
|
116
|
+
for i, ch in enumerate(out):
|
|
117
|
+
if otags[i] != 'c':
|
|
118
|
+
continue
|
|
119
|
+
if ch == '(':
|
|
120
|
+
depth += 1
|
|
121
|
+
elif ch == ')':
|
|
122
|
+
depth -= 1
|
|
123
|
+
lo = min(lo, depth)
|
|
124
|
+
if depth != 0 or lo < 0:
|
|
125
|
+
errs.append(f"unbalanced parentheses in rendered SQL (net depth {depth}) — a token may have "
|
|
126
|
+
"expanded into a comment or broken a clause.")
|
|
127
|
+
|
|
128
|
+
for w in warns: print(" ⚠ WARN " + w, file=sys.stderr)
|
|
129
|
+
for e in errs: print(" ✗ ERROR " + e, file=sys.stderr)
|
|
130
|
+
fail = bool(errs) or (strict and bool(warns))
|
|
131
|
+
if errs or warns:
|
|
132
|
+
print(f"render_and_validate: {len(errs)} error(s), {len(warns)} warning(s)"
|
|
133
|
+
+ ("" if not (strict and warns and not errs) else " (warnings fail under --strict)"),
|
|
134
|
+
file=sys.stderr)
|
|
135
|
+
sys.exit(1 if fail else 0)
|
|
136
|
+
PY
|
|
137
|
+
rc=$?
|
|
138
|
+
cat "$TMP/rendered"
|
|
139
|
+
exit "$rc"
|