@sitar_fiercer4c/skills 0.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.
- package/LICENSE +5 -0
- package/README.md +75 -0
- package/bin/install.js +45 -0
- package/package.json +29 -0
- package/skills/architecture-walkthrough/SKILL.md +223 -0
- package/skills/architecture-walkthrough/references/sections.md +29 -0
- package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
- package/skills/autotest-webapp-ui/SKILL.md +58 -0
- package/skills/backend-code-review/SKILL.md +386 -0
- package/skills/backend-code-review/references/report-format.md +333 -0
- package/skills/backend-code-review/scripts/list_routes.py +269 -0
- package/skills/backend-code-review/scripts/sweep.py +550 -0
- package/skills/backend-code-review/scripts/verify_citations.py +201 -0
- package/skills/be-brief/SKILL.md +18 -0
- package/skills/clarke-list-excel/SKILL.md +51 -0
- package/skills/clarke-list-excel/references/output-schema.md +125 -0
- package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
- package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
- package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
- package/skills/clarke-list-excel/scripts/run_all.py +63 -0
- package/skills/datalab-api/SKILL.md +163 -0
- package/skills/datalab-api/references/parameters-and-payload.md +121 -0
- package/skills/datalab-api/references/table-selection.md +35 -0
- package/skills/datalab-api/scripts/datalab_tables.py +365 -0
- package/skills/find-test-seam/SKILL.md +41 -0
- package/skills/frontend-code-review/SKILL.md +247 -0
- package/skills/frontend-code-review-2/SKILL.md +192 -0
- package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
- package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
- package/skills/murtaza-breif/SKILL.md +143 -0
- package/skills/murtaza-breif/scripts/save_brief.py +128 -0
- package/skills/pdf-to-json/SKILL.md +42 -0
- package/skills/pdf-to-json/references/output-schema.md +168 -0
- package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
- package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
- package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
- package/skills/record-api-traffic/SKILL.md +434 -0
- package/skills/record-api-traffic/references/reading-recordings.md +224 -0
- package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
- package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
- package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
- package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
- package/skills/record-api-traffic/scripts/preflight.py +528 -0
- package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
- package/skills/refac-wrt-business-goal/SKILL.md +305 -0
- package/skills/refac-wrt-business-goal/references/critic.md +170 -0
- package/skills/system-resource-triage/SKILL.md +180 -0
- package/skills/system-resource-triage/scripts/reap.sh +116 -0
- package/skills/system-resource-triage/scripts/triage.sh +111 -0
- package/skills/using-git-worktrees/SKILL.md +167 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Automating table selection with a model call
|
|
2
|
+
|
|
3
|
+
Read this when you need to pick the line-item table across many workbooks
|
|
4
|
+
programmatically. For a one-off, just read the previews and choose — the
|
|
5
|
+
principles in SKILL.md are the whole method, and this file only adds the
|
|
6
|
+
plumbing.
|
|
7
|
+
|
|
8
|
+
Send a prose description of the target plus a short preview of each table, and
|
|
9
|
+
constrain the output to ids so what comes back is something you can look up:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
class Selection(BaseModel):
|
|
15
|
+
table_ids: list[str]
|
|
16
|
+
reasoning: str
|
|
17
|
+
|
|
18
|
+
response = client.messages.parse(
|
|
19
|
+
model="claude-opus-4-7",
|
|
20
|
+
max_tokens=4000,
|
|
21
|
+
system=[{"type": "text", "text": SYSTEM, "cache_control": {"type": "ephemeral"}}],
|
|
22
|
+
thinking={"type": "adaptive"},
|
|
23
|
+
output_config={"effort": "high"},
|
|
24
|
+
messages=[{"role": "user", "content": prompt}],
|
|
25
|
+
output_format=Selection,
|
|
26
|
+
)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Build `prompt` from the `--preview` output: each table labelled with its exact
|
|
30
|
+
`id`, five rows, cells truncated to ~40 characters. Cache the system prompt when
|
|
31
|
+
looping over a directory — the target description is identical across files and
|
|
32
|
+
only the previews change.
|
|
33
|
+
|
|
34
|
+
`reasoning` is worth keeping even though you don't act on it. When a selection
|
|
35
|
+
looks wrong, it tells you whether the description or the preview was at fault.
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract table blocks from spreadsheets via the Datalab Convert API.
|
|
3
|
+
|
|
4
|
+
datalab_tables.py <file-or-dir> [--json out.json] [--preview out.md]
|
|
5
|
+
|
|
6
|
+
For each input file: submit to Datalab, poll until done, walk the block tree
|
|
7
|
+
for `block_type: "Table"`, and report each table's id, source cell range, and
|
|
8
|
+
size. Raw responses are cached on disk, so re-runs cost no credits.
|
|
9
|
+
|
|
10
|
+
Needs DATALAB_API_KEY in the environment or in a .env file found by walking up
|
|
11
|
+
from the current directory.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from html.parser import HTMLParser
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
API_URL = "https://www.datalab.to/api/v1/convert"
|
|
27
|
+
MODES = ("fast", "balanced", "accurate")
|
|
28
|
+
POLL_SECONDS = 2
|
|
29
|
+
POLL_ATTEMPTS = 300
|
|
30
|
+
|
|
31
|
+
MIME_TYPES = {
|
|
32
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
33
|
+
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
|
|
34
|
+
".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
|
|
35
|
+
".xls": "application/vnd.ms-excel",
|
|
36
|
+
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
37
|
+
".csv": "text/csv",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
PREVIEW_HEAD = 5
|
|
41
|
+
PREVIEW_TAIL = 3
|
|
42
|
+
CELL_CHARS = 28
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ----------------------------------------------------------------- auth
|
|
46
|
+
|
|
47
|
+
def api_key() -> str:
|
|
48
|
+
key = os.environ.get("DATALAB_API_KEY")
|
|
49
|
+
if key:
|
|
50
|
+
return key
|
|
51
|
+
for directory in [Path.cwd(), *Path.cwd().parents]:
|
|
52
|
+
for name in (".env.local", ".env"):
|
|
53
|
+
env = directory / name
|
|
54
|
+
if not env.exists():
|
|
55
|
+
continue
|
|
56
|
+
for line in env.read_text().splitlines():
|
|
57
|
+
if line.startswith("DATALAB_API_KEY="):
|
|
58
|
+
return line.split("=", 1)[1].strip().strip("'\"")
|
|
59
|
+
raise SystemExit("DATALAB_API_KEY not found in environment or any parent .env file")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------ api access
|
|
63
|
+
|
|
64
|
+
def strip_images(node):
|
|
65
|
+
"""Drop base64 image payloads.
|
|
66
|
+
|
|
67
|
+
Datalab always returns images for spreadsheets and `disable_image_extraction`
|
|
68
|
+
does not change that, so this is the only way to keep responses small. They
|
|
69
|
+
are stored twice: under the top-level `images` key and inside each Picture
|
|
70
|
+
block. On image-heavy sheets this is ~98% of the bytes.
|
|
71
|
+
|
|
72
|
+
Images are one-per-row product photos on many BOQ-style sheets. If you need
|
|
73
|
+
them, keep the raw response instead of stripping.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(node, list):
|
|
76
|
+
for item in node:
|
|
77
|
+
strip_images(item)
|
|
78
|
+
elif isinstance(node, dict):
|
|
79
|
+
node.pop("images", None)
|
|
80
|
+
strip_images(node.get("children") or [])
|
|
81
|
+
strip_images(node.get("json") or [])
|
|
82
|
+
return node
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cache_path(path: Path, mode: str, cache_dir: Path, keep_images: bool) -> Path:
|
|
86
|
+
"""Cache filename that can't serve one file's response for another.
|
|
87
|
+
|
|
88
|
+
The stem alone collides: a/report.xlsx, b/report.xlsx and report.csv would
|
|
89
|
+
share an entry and silently return each other's tables. keep_images belongs
|
|
90
|
+
in the key too, or the flag returns stripped data from an earlier run and
|
|
91
|
+
appears to do nothing.
|
|
92
|
+
"""
|
|
93
|
+
digest = hashlib.sha1(str(path.resolve()).encode()).hexdigest()[:8]
|
|
94
|
+
images = ".img" if keep_images else ""
|
|
95
|
+
return cache_dir / f"{path.stem}.{mode}{images}.{digest}.json"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def convert(path: Path, key: str, mode: str, cache_dir: Path, keep_images: bool) -> dict:
|
|
99
|
+
"""Submit one file and poll until the conversion completes."""
|
|
100
|
+
cache = cache_path(path, mode, cache_dir, keep_images)
|
|
101
|
+
if cache.exists():
|
|
102
|
+
return json.loads(cache.read_text())
|
|
103
|
+
|
|
104
|
+
mime = MIME_TYPES.get(path.suffix.lower(), "application/octet-stream")
|
|
105
|
+
headers = {"X-API-Key": key}
|
|
106
|
+
with path.open("rb") as fh:
|
|
107
|
+
resp = requests.post(
|
|
108
|
+
API_URL,
|
|
109
|
+
headers=headers,
|
|
110
|
+
files={"file": (path.name, fh, mime)},
|
|
111
|
+
data={"output_format": "json", "mode": mode},
|
|
112
|
+
timeout=120,
|
|
113
|
+
)
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
submitted = resp.json()
|
|
116
|
+
if not submitted.get("success"):
|
|
117
|
+
raise SystemExit(f"{path.name}: submit failed: {submitted.get('error')}")
|
|
118
|
+
|
|
119
|
+
check_url = submitted["request_check_url"]
|
|
120
|
+
for _ in range(POLL_ATTEMPTS):
|
|
121
|
+
result = requests.get(check_url, headers=headers, timeout=60).json()
|
|
122
|
+
if result.get("status") == "complete":
|
|
123
|
+
if not keep_images:
|
|
124
|
+
strip_images(result)
|
|
125
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
cache.write_text(json.dumps(result, indent=2))
|
|
127
|
+
return result
|
|
128
|
+
if result.get("status") == "failed" or result.get("error"):
|
|
129
|
+
raise SystemExit(f"{path.name}: conversion failed: {result.get('error')}")
|
|
130
|
+
time.sleep(POLL_SECONDS)
|
|
131
|
+
raise SystemExit(f"{path.name}: timed out after {POLL_ATTEMPTS * POLL_SECONDS}s")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------- block walking
|
|
135
|
+
|
|
136
|
+
def find_tables(node, page=None, out=None):
|
|
137
|
+
"""Depth-first walk of the block tree, collecting Table blocks.
|
|
138
|
+
|
|
139
|
+
Blocks nest arbitrarily, so recursion is the reliable way to reach them.
|
|
140
|
+
TableOfContents is included because Datalab sometimes classifies a plain
|
|
141
|
+
lead-in table that way.
|
|
142
|
+
"""
|
|
143
|
+
if out is None:
|
|
144
|
+
out = []
|
|
145
|
+
if isinstance(node, list):
|
|
146
|
+
for item in node:
|
|
147
|
+
find_tables(item, page, out)
|
|
148
|
+
return out
|
|
149
|
+
if not isinstance(node, dict):
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
block_type = node.get("block_type")
|
|
153
|
+
if block_type == "Page":
|
|
154
|
+
page = node.get("id", page)
|
|
155
|
+
if block_type in ("Table", "TableOfContents") and node.get("html"):
|
|
156
|
+
out.append({
|
|
157
|
+
"id": node.get("id"),
|
|
158
|
+
"page": page,
|
|
159
|
+
"html": node["html"],
|
|
160
|
+
"bbox": node.get("bbox"),
|
|
161
|
+
})
|
|
162
|
+
find_tables(node.get("children") or [], page, out)
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ----------------------------------------------------------- html -> grid
|
|
167
|
+
|
|
168
|
+
class TableParser(HTMLParser):
|
|
169
|
+
"""Flatten an HTML table into a list of row-lists of cell strings.
|
|
170
|
+
|
|
171
|
+
Cells are placed by occupancy rather than appended, so `colspan` and
|
|
172
|
+
`rowspan` consume the columns they actually cover. These sheets merge
|
|
173
|
+
header cells constantly, and appending would shift every later cell left —
|
|
174
|
+
a header spanning A:C followed by one over D would report that second
|
|
175
|
+
header as column 2 and misalign it against the data beneath.
|
|
176
|
+
|
|
177
|
+
A merged cell puts its text in the first covered position and leaves the
|
|
178
|
+
rest empty, which is how openpyxl reads the same merge in the source.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self):
|
|
182
|
+
super().__init__()
|
|
183
|
+
self.cells = {}
|
|
184
|
+
self.filled_rows = set()
|
|
185
|
+
self.row_idx = -1
|
|
186
|
+
self.col_idx = 0
|
|
187
|
+
self.cell = None
|
|
188
|
+
self.span = (1, 1)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _span(attrs, name):
|
|
192
|
+
try:
|
|
193
|
+
return max(1, int(dict(attrs).get(name, 1)))
|
|
194
|
+
except (TypeError, ValueError):
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
def handle_starttag(self, tag, attrs):
|
|
198
|
+
if tag == "tr":
|
|
199
|
+
self.row_idx += 1
|
|
200
|
+
self.col_idx = 0
|
|
201
|
+
elif tag in ("td", "th"):
|
|
202
|
+
self.cell = []
|
|
203
|
+
self.span = (self._span(attrs, "colspan"), self._span(attrs, "rowspan"))
|
|
204
|
+
|
|
205
|
+
def handle_endtag(self, tag):
|
|
206
|
+
if tag not in ("td", "th") or self.cell is None:
|
|
207
|
+
return
|
|
208
|
+
text = re.sub(r"\s+", " ", "".join(self.cell)).strip()
|
|
209
|
+
self.cell = None
|
|
210
|
+
colspan, rowspan = self.span
|
|
211
|
+
while (self.row_idx, self.col_idx) in self.cells:
|
|
212
|
+
self.col_idx += 1
|
|
213
|
+
for dr in range(rowspan):
|
|
214
|
+
for dc in range(colspan):
|
|
215
|
+
self.cells[(self.row_idx + dr, self.col_idx + dc)] = text if dr == dc == 0 else ""
|
|
216
|
+
self.filled_rows.add(self.row_idx)
|
|
217
|
+
self.col_idx += colspan
|
|
218
|
+
|
|
219
|
+
def handle_data(self, data):
|
|
220
|
+
if self.cell is not None:
|
|
221
|
+
self.cell.append(data)
|
|
222
|
+
|
|
223
|
+
def grid(self) -> list[list[str]]:
|
|
224
|
+
if not self.cells:
|
|
225
|
+
return []
|
|
226
|
+
width = max(c for _, c in self.cells) + 1
|
|
227
|
+
rows = sorted({r for r, _ in self.cells} | self.filled_rows)
|
|
228
|
+
return [[self.cells.get((r, c), "") for c in range(width)] for r in rows]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def to_grid(html: str) -> list[list[str]]:
|
|
232
|
+
parser = TableParser()
|
|
233
|
+
parser.feed(html)
|
|
234
|
+
return parser.grid()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------- bbox -> A1
|
|
238
|
+
|
|
239
|
+
def col_letter(n: int) -> str:
|
|
240
|
+
"""1 -> A, 27 -> AA."""
|
|
241
|
+
out = ""
|
|
242
|
+
while n > 0:
|
|
243
|
+
n, rem = divmod(n - 1, 26)
|
|
244
|
+
out = chr(65 + rem) + out
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def excel_range(bbox) -> str:
|
|
249
|
+
"""Spreadsheet bboxes are [col0, row0, col1, row1], 1-indexed inclusive.
|
|
250
|
+
|
|
251
|
+
This is cell coordinates, not pixels or points. Undocumented, but verified
|
|
252
|
+
against openpyxl on real workbooks.
|
|
253
|
+
|
|
254
|
+
Only Table and Text blocks use that convention. A Page bbox is a sheet
|
|
255
|
+
extent starting [0, 0, ...] and a Picture bbox is continuous, so neither is
|
|
256
|
+
a range. Both are rejected here rather than converted, because col_letter(0)
|
|
257
|
+
is the empty string and would otherwise yield a plausible-looking "0:J19".
|
|
258
|
+
"""
|
|
259
|
+
if not bbox or len(bbox) != 4:
|
|
260
|
+
return "n/a"
|
|
261
|
+
c0, r0, c1, r1 = (int(v) for v in bbox)
|
|
262
|
+
if min(c0, r0, c1, r1) < 1:
|
|
263
|
+
return "n/a"
|
|
264
|
+
return f"{col_letter(c0)}{r0}:{col_letter(c1)}{r1}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# -------------------------------------------------------------- reporting
|
|
268
|
+
|
|
269
|
+
def md_row(cells: list[str], index: str = "") -> str:
|
|
270
|
+
safe = [c.replace("|", "\\|").replace("\n", " ")[:CELL_CHARS] or " " for c in cells]
|
|
271
|
+
return "| " + index + " | " + " | ".join(safe) + " |"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def render(grid: list[list[str]]) -> list[str]:
|
|
275
|
+
"""Markdown preview: header, first rows and last rows with a gap marker."""
|
|
276
|
+
if not grid:
|
|
277
|
+
return ["_empty table_"]
|
|
278
|
+
headers = [h or f"col_{i}" for i, h in enumerate(grid[0])]
|
|
279
|
+
body = grid[1:]
|
|
280
|
+
|
|
281
|
+
lines = [md_row(headers, "#"), md_row(["---"] * len(headers), "---")]
|
|
282
|
+
if len(body) <= PREVIEW_HEAD + PREVIEW_TAIL:
|
|
283
|
+
shown, gap_after = [(i, r) for i, r in enumerate(body, 1)], None
|
|
284
|
+
else:
|
|
285
|
+
shown = [(i, r) for i, r in enumerate(body[:PREVIEW_HEAD], 1)]
|
|
286
|
+
shown += [(i, r) for i, r in enumerate(body[-PREVIEW_TAIL:], len(body) - PREVIEW_TAIL + 1)]
|
|
287
|
+
gap_after = PREVIEW_HEAD
|
|
288
|
+
|
|
289
|
+
for pos, (num, row) in enumerate(shown):
|
|
290
|
+
if gap_after is not None and pos == gap_after:
|
|
291
|
+
lines.append(md_row(["…"] * len(headers), "…"))
|
|
292
|
+
lines.append(md_row(row, str(num)))
|
|
293
|
+
return lines
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def describe(path: Path, tables: list[dict]) -> list[dict]:
|
|
297
|
+
out = []
|
|
298
|
+
for table in tables:
|
|
299
|
+
grid = to_grid(table["html"])
|
|
300
|
+
out.append({
|
|
301
|
+
"id": table["id"],
|
|
302
|
+
"bbox": table["bbox"],
|
|
303
|
+
"excel_range": excel_range(table["bbox"]),
|
|
304
|
+
"rows": max(len(grid) - 1, 0),
|
|
305
|
+
"columns": len(grid[0]) if grid else 0,
|
|
306
|
+
"headers": grid[0] if grid else [],
|
|
307
|
+
})
|
|
308
|
+
return out
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def collect_inputs(source: Path) -> list[Path]:
|
|
312
|
+
if source.is_dir():
|
|
313
|
+
return sorted(p for p in source.iterdir() if p.suffix.lower() in MIME_TYPES)
|
|
314
|
+
return [source]
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def main():
|
|
318
|
+
parser = argparse.ArgumentParser(description=__doc__,
|
|
319
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
320
|
+
parser.add_argument("input", type=Path, help="a spreadsheet file or a directory of them")
|
|
321
|
+
parser.add_argument("--json", type=Path, help="write the table index as JSON")
|
|
322
|
+
parser.add_argument("--preview", type=Path, help="write a markdown preview of every table")
|
|
323
|
+
parser.add_argument("--mode", choices=MODES, default="fast",
|
|
324
|
+
help="Datalab mode (default: fast; has no effect on spreadsheets)")
|
|
325
|
+
parser.add_argument("--cache-dir", type=Path, default=Path("datalab_raw"),
|
|
326
|
+
help="where to cache raw API responses (default: ./datalab_raw)")
|
|
327
|
+
parser.add_argument("--keep-images", action="store_true",
|
|
328
|
+
help="keep base64 image payloads instead of stripping them")
|
|
329
|
+
args = parser.parse_args()
|
|
330
|
+
|
|
331
|
+
files = collect_inputs(args.input)
|
|
332
|
+
if not files:
|
|
333
|
+
parser.error(f"no spreadsheet files found at {args.input}")
|
|
334
|
+
|
|
335
|
+
key = api_key()
|
|
336
|
+
index, preview_lines = {}, ["# Datalab table extraction\n"]
|
|
337
|
+
|
|
338
|
+
for path in files:
|
|
339
|
+
print(f"{path.name}", file=sys.stderr)
|
|
340
|
+
result = convert(path, key, args.mode, args.cache_dir, args.keep_images)
|
|
341
|
+
tables = find_tables(result.get("json") or {})
|
|
342
|
+
index[path.name] = describe(path, tables)
|
|
343
|
+
print(f" {len(tables)} table block(s)", file=sys.stderr)
|
|
344
|
+
|
|
345
|
+
preview_lines.append(f"\n## {path.name}\n")
|
|
346
|
+
for n, table in enumerate(tables):
|
|
347
|
+
grid = to_grid(table["html"])
|
|
348
|
+
preview_lines.append(f"\n### Table {n} — `{table['id']}`\n")
|
|
349
|
+
preview_lines.append(
|
|
350
|
+
f"{max(len(grid) - 1, 0)} data rows x {len(grid[0]) if grid else 0} columns"
|
|
351
|
+
f" — bbox `{table['bbox']}` — range `{excel_range(table['bbox'])}`\n")
|
|
352
|
+
preview_lines.extend(render(grid))
|
|
353
|
+
|
|
354
|
+
if args.preview:
|
|
355
|
+
args.preview.write_text("\n".join(preview_lines) + "\n")
|
|
356
|
+
print(f"preview -> {args.preview}", file=sys.stderr)
|
|
357
|
+
if args.json:
|
|
358
|
+
args.json.write_text(json.dumps(index, indent=2) + "\n")
|
|
359
|
+
print(f"index -> {args.json}", file=sys.stderr)
|
|
360
|
+
if not args.json and not args.preview:
|
|
361
|
+
print(json.dumps(index, indent=2))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
if __name__ == "__main__":
|
|
365
|
+
main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: find-test-seam
|
|
3
|
+
description: Find the input and output seams in existing code that a characterization test can be pinned to before a business-logic refactor. Use this skill when a business-goal node/edge graph and an API recording already exist and only the seam is wanted — if the graph does not exist yet, refac-wrt-business-goal builds it and names the seams in one pass. It names and ranks seams; it does not write tests and does not change code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Find the test seam
|
|
7
|
+
|
|
8
|
+
Given a happy-path API recording and a business-goal node/edge graph, name the input and output seam
|
|
9
|
+
a characterization test can be pinned to. You name seams. You do not write tests or change code.
|
|
10
|
+
|
|
11
|
+
## Hard constraint
|
|
12
|
+
|
|
13
|
+
1. Touch only files in backend — `services/`, `utils/`.
|
|
14
|
+
2. Find a seam which is currently existing in code. Do not create a new one.
|
|
15
|
+
|
|
16
|
+
## Method
|
|
17
|
+
|
|
18
|
+
- Filter the recording to state-changing 2xx calls; ignore OPTIONS, 304s and polling GETs.
|
|
19
|
+
- A `202` means the work is fire-and-forget: the response body is not the outcome, and the seam
|
|
20
|
+
does not await it. Name the signal the test polls for completion.
|
|
21
|
+
- Input seam: narrowest existing function with every `E` node on its input side. Count what it
|
|
22
|
+
fetches, reads and imports, not just its parameters.
|
|
23
|
+
- Check it is already reachable — exported, on the default export. If you would have to export it,
|
|
24
|
+
you are creating a seam, not finding one.
|
|
25
|
+
- Output seam: where every outcome becomes durable. Read the `catch` block — the refusal lives there.
|
|
26
|
+
- Reject any seam the refactor would dissolve. Pure helpers die; orchestrator entry and terminal
|
|
27
|
+
write survive.
|
|
28
|
+
- Grep every call site of the output seam; find what distinguishes the in-graph ones.
|
|
29
|
+
- Read that function's body — retry or pruning logic silently voids the assertions you plan.
|
|
30
|
+
- Map each node's output to a field on the output-seam payload. An unmappable node means either a
|
|
31
|
+
misplaced seam or a node not implemented — say which, and what can be checked instead.
|
|
32
|
+
- Rank 2–3 seam pairs on coverage versus testability, checking each can be driven today with the
|
|
33
|
+
fixtures and injection points that exist. An existing script that already invokes the pair is the
|
|
34
|
+
strongest evidence it is drivable. Recommend one.
|
|
35
|
+
|
|
36
|
+
## Output format
|
|
37
|
+
|
|
38
|
+
First answer the exact question the user is asking. No extra context or extra info. Fewer words is
|
|
39
|
+
better. Use progressive disclosure — keep the headings `## Input seam`, `## Output seam`,
|
|
40
|
+
`## Candidates`, `## Blockers`, and be brief under each: a 30–60 word sentence, or 10–15 word
|
|
41
|
+
bullets for technical points. Cite `file:line` for every claim.
|