@structupath/pi-steel 0.2.2 → 0.2.3
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/DATA_PROVENANCE.json +23 -0
- package/DATA_PROVENANCE.md +35 -0
- package/PUBLIC_DATA_POLICY.md +52 -0
- package/README.md +114 -33
- package/docs/assets/pi-steel-demo.gif +0 -0
- package/docs/assets/pi-steel-gallery.webp +0 -0
- package/package.json +35 -4
- package/pyproject.toml +28 -0
- package/requirements-dev.txt +5 -0
- package/requirements-render.txt +1 -0
- package/requirements-tested.txt +9 -0
- package/requirements.txt +7 -0
- package/scripts/check-data-provenance.py +140 -0
- package/scripts/check-public-data.py +401 -0
- package/scripts/doctor.py +127 -0
- package/skills/_shared/bootstrap.py +32 -0
- package/skills/_shared/pi_steel/__init__.py +47 -0
- package/skills/_shared/pi_steel/cli.py +167 -0
- package/skills/_shared/pi_steel/contracts.py +87 -0
- package/skills/_shared/pi_steel/geometry_verify.py +243 -0
- package/skills/_shared/pi_steel/parsing.py +205 -0
- package/skills/_shared/pi_steel/run_manifest.py +317 -0
- package/skills/_shared/pi_steel/validation.py +597 -0
- package/skills/_shared/schemas/estimate-package.schema.json +424 -0
- package/skills/_shared/schemas/nest-result.schema.json +443 -0
- package/skills/_shared/schemas/run-manifest.schema.json +145 -0
- package/skills/steel-estimate/SKILL.md +119 -0
- package/skills/steel-estimate/references/estimate-package-example.json +91 -0
- package/skills/steel-estimate/references/output-contract.md +47 -0
- package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
- package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
- package/skills/steel-nest/SKILL.md +50 -23
- package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
- package/skills/steel-nest/references/example_job.json +21 -25
- package/skills/steel-nest/references/job_template.json +11 -9
- package/skills/steel-nest/scripts/nest.py +1276 -250
- package/skills/steel-rfq/SKILL.md +93 -175
- package/skills/steel-rfq/assets/company-profile.example.json +11 -5
- package/skills/steel-rfq/references/rfq-input.md +58 -0
- package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
- package/skills/steel-rfq/scripts/recalc.py +33 -10
- package/skills/steel-takeoff/SKILL.md +12 -8
- package/skills/steel-takeoff/assets/bom-template.csv +1 -1
- package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
- package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
- package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
- package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail when public repository files contain known private-data indicators."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from bisect import bisect_left
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
16
|
+
SKIP_PARTS = {".git", ".a5c", "__pycache__", "node_modules"}
|
|
17
|
+
SKIP_FILES = {Path("scripts/check-public-data.py")}
|
|
18
|
+
FORBIDDEN_BINARY_SUFFIXES = {
|
|
19
|
+
".doc",
|
|
20
|
+
".docx",
|
|
21
|
+
".dxf",
|
|
22
|
+
".jpeg",
|
|
23
|
+
".jpg",
|
|
24
|
+
".pdf",
|
|
25
|
+
".png",
|
|
26
|
+
".xls",
|
|
27
|
+
".xlsx",
|
|
28
|
+
}
|
|
29
|
+
SENSITIVE_SUFFIXES = {".key", ".p12", ".pem", ".pfx"}
|
|
30
|
+
PRIVATE_KEY_NAMES = {
|
|
31
|
+
"id_dsa",
|
|
32
|
+
"id_ecdsa",
|
|
33
|
+
"id_ed25519",
|
|
34
|
+
"id_rsa",
|
|
35
|
+
"private-key",
|
|
36
|
+
"private_key",
|
|
37
|
+
}
|
|
38
|
+
AUDITED_PUBLIC_BINARY_SHA256 = {
|
|
39
|
+
Path("docs/assets/pi-steel-demo.gif"):
|
|
40
|
+
"ae6ad7286fc5f1eca31e960d4ac4414b7a32a566b975ddacc7d662824c34d91c",
|
|
41
|
+
Path("docs/assets/pi-steel-gallery.webp"):
|
|
42
|
+
"9d8f2b1dedb00fa6c53c78f4578a12225f84bded4f7bbabe33182486372d12a8",
|
|
43
|
+
}
|
|
44
|
+
PATTERNS = {
|
|
45
|
+
"private operating-company claim": re.compile(
|
|
46
|
+
r"team behind (?:a|the) production structural[- ]steel", re.I
|
|
47
|
+
),
|
|
48
|
+
"local absolute path": re.compile(r"(?:/Users/|/home/|[A-Z]:\\\\Users\\\\)"),
|
|
49
|
+
"realistic sales-order identifier": re.compile(r"\bSO-\d{3,}\b", re.I),
|
|
50
|
+
"current-market pricing claim": re.compile(r"\bcurrent (?:market )?rates?\b", re.I),
|
|
51
|
+
"contact email": re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I),
|
|
52
|
+
"phone number": re.compile(
|
|
53
|
+
r"(?<!\d)(?:\+?1[-. ]?)?\(?[2-9]\d{2}\)?[-. ]\d{3}[-. ]\d{4}(?!\d)"
|
|
54
|
+
),
|
|
55
|
+
"credential assignment": re.compile(
|
|
56
|
+
r"\b(?:api[_-]?key|password|secret|token)\s*[:=]\s*"
|
|
57
|
+
r"(?:[\"'][^\"'\r\n]+[\"']|[^\s#;,]+)",
|
|
58
|
+
re.I,
|
|
59
|
+
),
|
|
60
|
+
"private key material": re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"),
|
|
61
|
+
"AWS access key identifier": re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
|
|
62
|
+
"GitHub access token": re.compile(
|
|
63
|
+
r"\b(?:gh[opusr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b"
|
|
64
|
+
),
|
|
65
|
+
"OpenAI API key": re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b"),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_audited_public_binary(relative: Path, content: bytes) -> bool:
|
|
70
|
+
expected = AUDITED_PUBLIC_BINARY_SHA256.get(relative)
|
|
71
|
+
return expected is not None and hashlib.sha256(content).hexdigest() == expected
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def tracked_files(root: Path = ROOT) -> list[Path]:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
77
|
+
cwd=root,
|
|
78
|
+
check=False,
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
)
|
|
82
|
+
if result.returncode == 0:
|
|
83
|
+
return [root / line for line in result.stdout.splitlines() if line]
|
|
84
|
+
return [
|
|
85
|
+
path
|
|
86
|
+
for path in root.rglob("*")
|
|
87
|
+
if path.is_file() and not any(part in SKIP_PARTS for part in path.parts)
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan_patterns(root: Path = ROOT) -> dict[str, re.Pattern[str]]:
|
|
92
|
+
patterns = dict(PATTERNS)
|
|
93
|
+
private_terms = root / ".pi-steel" / "private-terms.txt"
|
|
94
|
+
if private_terms.is_file():
|
|
95
|
+
terms = [
|
|
96
|
+
line.strip()
|
|
97
|
+
for line in private_terms.read_text(encoding="utf-8").splitlines()
|
|
98
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
99
|
+
]
|
|
100
|
+
if terms:
|
|
101
|
+
patterns["private local denylist term"] = re.compile(
|
|
102
|
+
"|".join(re.escape(term) for term in sorted(terms, key=len, reverse=True)),
|
|
103
|
+
re.I,
|
|
104
|
+
)
|
|
105
|
+
return patterns
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def sensitive_path_reason(relative: Path) -> str | None:
|
|
109
|
+
name = relative.name.lower()
|
|
110
|
+
if name == ".env" or name.startswith(".env."):
|
|
111
|
+
return "sensitive environment file"
|
|
112
|
+
if relative.suffix.lower() in SENSITIVE_SUFFIXES:
|
|
113
|
+
return "sensitive key or certificate file"
|
|
114
|
+
if name in PRIVATE_KEY_NAMES:
|
|
115
|
+
return "private key file"
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def scan_paths(
|
|
120
|
+
paths: list[Path],
|
|
121
|
+
*,
|
|
122
|
+
root: Path = ROOT,
|
|
123
|
+
patterns: dict[str, re.Pattern[str]] | None = None,
|
|
124
|
+
) -> list[str]:
|
|
125
|
+
findings: list[str] = []
|
|
126
|
+
active_patterns = scan_patterns(root) if patterns is None else patterns
|
|
127
|
+
for path in paths:
|
|
128
|
+
relative = path.relative_to(root)
|
|
129
|
+
if (
|
|
130
|
+
relative in SKIP_FILES
|
|
131
|
+
or any(part in SKIP_PARTS for part in relative.parts)
|
|
132
|
+
or not path.is_file()
|
|
133
|
+
):
|
|
134
|
+
continue
|
|
135
|
+
sensitive_reason = sensitive_path_reason(relative)
|
|
136
|
+
if sensitive_reason:
|
|
137
|
+
findings.append(f"{relative}: {sensitive_reason}")
|
|
138
|
+
continue
|
|
139
|
+
suffix = path.suffix.lower()
|
|
140
|
+
if suffix in FORBIDDEN_BINARY_SUFFIXES:
|
|
141
|
+
findings.append(f"{relative}: public repository must not contain {suffix} artifacts")
|
|
142
|
+
continue
|
|
143
|
+
content = path.read_bytes()
|
|
144
|
+
if is_audited_public_binary(relative, content):
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
text = content.decode("utf-8")
|
|
148
|
+
except UnicodeDecodeError:
|
|
149
|
+
findings.append(f"{relative}: unknown binary file")
|
|
150
|
+
continue
|
|
151
|
+
if "\x00" in text:
|
|
152
|
+
findings.append(f"{relative}: unknown binary file")
|
|
153
|
+
continue
|
|
154
|
+
newline_offsets = [
|
|
155
|
+
index for index, character in enumerate(text) if character == "\n"
|
|
156
|
+
]
|
|
157
|
+
for label, pattern in active_patterns.items():
|
|
158
|
+
for match in pattern.finditer(text):
|
|
159
|
+
line = bisect_left(newline_offsets, match.start()) + 1
|
|
160
|
+
findings.append(f"{relative}:{line}: {label}")
|
|
161
|
+
return findings
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _scan_bytes(
|
|
165
|
+
relative: Path,
|
|
166
|
+
content: bytes,
|
|
167
|
+
*,
|
|
168
|
+
patterns: dict[str, re.Pattern[str]],
|
|
169
|
+
prefix: str = "",
|
|
170
|
+
) -> list[str]:
|
|
171
|
+
if relative in SKIP_FILES or any(part in SKIP_PARTS for part in relative.parts):
|
|
172
|
+
return []
|
|
173
|
+
sensitive_reason = sensitive_path_reason(relative)
|
|
174
|
+
if sensitive_reason:
|
|
175
|
+
return [f"{prefix}{relative}: {sensitive_reason}"]
|
|
176
|
+
suffix = relative.suffix.lower()
|
|
177
|
+
if suffix in FORBIDDEN_BINARY_SUFFIXES:
|
|
178
|
+
return [
|
|
179
|
+
f"{prefix}{relative}: public repository must not contain {suffix} artifacts"
|
|
180
|
+
]
|
|
181
|
+
if is_audited_public_binary(relative, content):
|
|
182
|
+
return []
|
|
183
|
+
try:
|
|
184
|
+
text = content.decode("utf-8")
|
|
185
|
+
except UnicodeDecodeError:
|
|
186
|
+
return [f"{prefix}{relative}: unknown binary file"]
|
|
187
|
+
if "\x00" in text:
|
|
188
|
+
return [f"{prefix}{relative}: unknown binary file"]
|
|
189
|
+
|
|
190
|
+
findings: list[str] = []
|
|
191
|
+
newline_offsets = [
|
|
192
|
+
index for index, character in enumerate(text) if character == "\n"
|
|
193
|
+
]
|
|
194
|
+
for label, pattern in patterns.items():
|
|
195
|
+
for match in pattern.finditer(text):
|
|
196
|
+
line = bisect_left(newline_offsets, match.start()) + 1
|
|
197
|
+
findings.append(f"{prefix}{relative}:{line}: {label}")
|
|
198
|
+
return findings
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def staged_findings(
|
|
202
|
+
root: Path = ROOT,
|
|
203
|
+
*,
|
|
204
|
+
patterns: dict[str, re.Pattern[str]] | None = None,
|
|
205
|
+
) -> list[str]:
|
|
206
|
+
"""Scan the exact stage-zero blobs that would be included in the next commit."""
|
|
207
|
+
changed = subprocess.run(
|
|
208
|
+
["git", "diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR"],
|
|
209
|
+
cwd=root,
|
|
210
|
+
check=False,
|
|
211
|
+
capture_output=True,
|
|
212
|
+
)
|
|
213
|
+
if changed.returncode != 0:
|
|
214
|
+
return []
|
|
215
|
+
|
|
216
|
+
active_patterns = scan_patterns(root) if patterns is None else patterns
|
|
217
|
+
findings: list[str] = []
|
|
218
|
+
blob_cache: dict[str, bytes] = {}
|
|
219
|
+
for raw_path in changed.stdout.split(b"\0"):
|
|
220
|
+
if not raw_path:
|
|
221
|
+
continue
|
|
222
|
+
relative = Path(raw_path.decode("utf-8", errors="surrogateescape"))
|
|
223
|
+
index_entry = subprocess.run(
|
|
224
|
+
["git", "ls-files", "--stage", "-z", "--", str(relative)],
|
|
225
|
+
cwd=root,
|
|
226
|
+
check=False,
|
|
227
|
+
capture_output=True,
|
|
228
|
+
)
|
|
229
|
+
entries = [entry for entry in index_entry.stdout.split(b"\0") if entry]
|
|
230
|
+
stage_zero = [
|
|
231
|
+
entry for entry in entries if entry.split(b"\t", 1)[0].endswith(b" 0")
|
|
232
|
+
]
|
|
233
|
+
if not stage_zero:
|
|
234
|
+
continue
|
|
235
|
+
metadata, _ = stage_zero[0].split(b"\t", 1)
|
|
236
|
+
_, object_id, _ = metadata.decode("ascii").split()
|
|
237
|
+
if object_id not in blob_cache:
|
|
238
|
+
blob = subprocess.run(
|
|
239
|
+
["git", "cat-file", "blob", object_id],
|
|
240
|
+
cwd=root,
|
|
241
|
+
check=False,
|
|
242
|
+
capture_output=True,
|
|
243
|
+
)
|
|
244
|
+
if blob.returncode != 0:
|
|
245
|
+
continue
|
|
246
|
+
blob_cache[object_id] = blob.stdout
|
|
247
|
+
findings.extend(
|
|
248
|
+
_scan_bytes(
|
|
249
|
+
relative,
|
|
250
|
+
blob_cache[object_id],
|
|
251
|
+
patterns=active_patterns,
|
|
252
|
+
prefix="staged ",
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
return findings
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def revision_findings(
|
|
259
|
+
revision_args: list[str],
|
|
260
|
+
root: Path = ROOT,
|
|
261
|
+
*,
|
|
262
|
+
patterns: dict[str, re.Pattern[str]] | None = None,
|
|
263
|
+
) -> list[str]:
|
|
264
|
+
"""Scan committed blobs reachable from the supplied ``git rev-list`` arguments."""
|
|
265
|
+
commits = subprocess.run(
|
|
266
|
+
["git", "rev-list", *revision_args],
|
|
267
|
+
cwd=root,
|
|
268
|
+
check=False,
|
|
269
|
+
capture_output=True,
|
|
270
|
+
text=True,
|
|
271
|
+
)
|
|
272
|
+
if commits.returncode != 0:
|
|
273
|
+
raise ValueError(commits.stderr.strip() or "invalid Git revision")
|
|
274
|
+
|
|
275
|
+
active_patterns = scan_patterns(root) if patterns is None else patterns
|
|
276
|
+
findings: list[str] = []
|
|
277
|
+
blob_cache: dict[str, bytes] = {}
|
|
278
|
+
scanned: set[tuple[str, Path]] = set()
|
|
279
|
+
for commit in commits.stdout.splitlines():
|
|
280
|
+
tree = subprocess.run(
|
|
281
|
+
["git", "ls-tree", "-r", "-z", commit],
|
|
282
|
+
cwd=root,
|
|
283
|
+
check=False,
|
|
284
|
+
capture_output=True,
|
|
285
|
+
)
|
|
286
|
+
if tree.returncode != 0:
|
|
287
|
+
continue
|
|
288
|
+
for entry in tree.stdout.split(b"\0"):
|
|
289
|
+
if not entry:
|
|
290
|
+
continue
|
|
291
|
+
metadata, raw_path = entry.split(b"\t", 1)
|
|
292
|
+
_, object_type, object_id = metadata.decode("ascii").split()
|
|
293
|
+
if object_type != "blob":
|
|
294
|
+
continue
|
|
295
|
+
relative = Path(raw_path.decode("utf-8", errors="surrogateescape"))
|
|
296
|
+
identity = (object_id, relative)
|
|
297
|
+
if identity in scanned:
|
|
298
|
+
continue
|
|
299
|
+
scanned.add(identity)
|
|
300
|
+
if object_id not in blob_cache:
|
|
301
|
+
blob = subprocess.run(
|
|
302
|
+
["git", "cat-file", "blob", object_id],
|
|
303
|
+
cwd=root,
|
|
304
|
+
check=False,
|
|
305
|
+
capture_output=True,
|
|
306
|
+
)
|
|
307
|
+
if blob.returncode != 0:
|
|
308
|
+
continue
|
|
309
|
+
blob_cache[object_id] = blob.stdout
|
|
310
|
+
findings.extend(
|
|
311
|
+
_scan_bytes(
|
|
312
|
+
relative,
|
|
313
|
+
blob_cache[object_id],
|
|
314
|
+
patterns=active_patterns,
|
|
315
|
+
prefix=f"commit {commit[:12]} ",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
return findings
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def revision_metadata_findings(
|
|
322
|
+
revision_args: list[str],
|
|
323
|
+
root: Path = ROOT,
|
|
324
|
+
) -> list[str]:
|
|
325
|
+
"""Report non-noreply author addresses without exposing the address itself."""
|
|
326
|
+
commits = subprocess.run(
|
|
327
|
+
["git", "log", "--format=%H%x00%ae", *revision_args],
|
|
328
|
+
cwd=root,
|
|
329
|
+
check=False,
|
|
330
|
+
capture_output=True,
|
|
331
|
+
text=True,
|
|
332
|
+
)
|
|
333
|
+
if commits.returncode != 0:
|
|
334
|
+
raise ValueError(commits.stderr.strip() or "invalid Git revision")
|
|
335
|
+
|
|
336
|
+
findings: list[str] = []
|
|
337
|
+
seen_addresses: set[str] = set()
|
|
338
|
+
for entry in commits.stdout.splitlines():
|
|
339
|
+
if "\0" not in entry:
|
|
340
|
+
continue
|
|
341
|
+
commit, address = entry.split("\0", 1)
|
|
342
|
+
normalized = address.strip().lower()
|
|
343
|
+
if (
|
|
344
|
+
not normalized
|
|
345
|
+
or normalized in seen_addresses
|
|
346
|
+
or normalized.endswith("@users.noreply.github.com")
|
|
347
|
+
):
|
|
348
|
+
continue
|
|
349
|
+
seen_addresses.add(normalized)
|
|
350
|
+
findings.append(
|
|
351
|
+
f"commit {commit[:12]} author metadata: non-noreply email address"
|
|
352
|
+
)
|
|
353
|
+
return findings
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
357
|
+
parser = argparse.ArgumentParser(
|
|
358
|
+
description="Check public repository content without printing matched values."
|
|
359
|
+
)
|
|
360
|
+
revisions = parser.add_mutually_exclusive_group()
|
|
361
|
+
revisions.add_argument(
|
|
362
|
+
"--history",
|
|
363
|
+
action="store_true",
|
|
364
|
+
help="also scan every commit reachable from every local ref",
|
|
365
|
+
)
|
|
366
|
+
revisions.add_argument(
|
|
367
|
+
"--range",
|
|
368
|
+
metavar="REVISION_RANGE",
|
|
369
|
+
help="also scan commits selected by a git rev-list revision range",
|
|
370
|
+
)
|
|
371
|
+
return parser.parse_args(argv)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def main(argv: list[str] | None = None) -> int:
|
|
375
|
+
args = parse_args(argv)
|
|
376
|
+
findings = scan_paths(tracked_files(), root=ROOT)
|
|
377
|
+
findings.extend(staged_findings(ROOT))
|
|
378
|
+
if args.history:
|
|
379
|
+
findings.extend(revision_findings(["--all"], ROOT))
|
|
380
|
+
findings.extend(revision_metadata_findings(["--all"], ROOT))
|
|
381
|
+
elif args.range:
|
|
382
|
+
try:
|
|
383
|
+
findings.extend(revision_findings([args.range], ROOT))
|
|
384
|
+
findings.extend(revision_metadata_findings([args.range], ROOT))
|
|
385
|
+
except ValueError as error:
|
|
386
|
+
print(f"Public-data check could not scan revisions: {error}", file=sys.stderr)
|
|
387
|
+
return 2
|
|
388
|
+
findings = list(dict.fromkeys(findings))
|
|
389
|
+
|
|
390
|
+
if findings:
|
|
391
|
+
print("Public-data check failed:", file=sys.stderr)
|
|
392
|
+
for finding in findings:
|
|
393
|
+
print(f" - {finding}", file=sys.stderr)
|
|
394
|
+
return 1
|
|
395
|
+
|
|
396
|
+
print("Public-data check passed.")
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Diagnose the supported pi-steel Python runtime and optional capabilities."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import importlib
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SHARED_ROOT = Path(__file__).resolve().parents[1] / "skills" / "_shared"
|
|
17
|
+
sys.path.insert(0, str(SHARED_ROOT))
|
|
18
|
+
bootstrap_shared = getattr(importlib.import_module("bootstrap"), "bootstrap_shared")
|
|
19
|
+
bootstrap_shared(__file__)
|
|
20
|
+
outcome_exit_code = getattr(importlib.import_module("pi_steel"), "outcome_exit_code")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
REQUIRED_MODULES = {
|
|
24
|
+
"jsonschema": "contract validation",
|
|
25
|
+
"openpyxl": "RFQ workbook generation",
|
|
26
|
+
"pandas": "spreadsheet adapters",
|
|
27
|
+
}
|
|
28
|
+
OPTIONAL_CAPABILITIES = {
|
|
29
|
+
"nest_rendering": ("ezdxf", "matplotlib", "numpy"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _module_status(name, module_finder):
|
|
34
|
+
available = module_finder(name) is not None
|
|
35
|
+
item = {"name": name, "kind": "python_module", "available": available}
|
|
36
|
+
if available:
|
|
37
|
+
try:
|
|
38
|
+
item["version"] = version(name)
|
|
39
|
+
except PackageNotFoundError:
|
|
40
|
+
item["version"] = "unknown"
|
|
41
|
+
return item
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def diagnose(
|
|
45
|
+
*,
|
|
46
|
+
version_info=None,
|
|
47
|
+
module_finder=importlib.util.find_spec,
|
|
48
|
+
command_finder=shutil.which,
|
|
49
|
+
):
|
|
50
|
+
current = version_info or sys.version_info
|
|
51
|
+
python_supported = (3, 11) <= tuple(current[:2]) < (3, 14)
|
|
52
|
+
required = [
|
|
53
|
+
{
|
|
54
|
+
"name": "python",
|
|
55
|
+
"kind": "runtime",
|
|
56
|
+
"available": python_supported,
|
|
57
|
+
"version": ".".join(str(value) for value in current[:3]),
|
|
58
|
+
"supported": ">=3.11,<3.14",
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
for name, purpose in REQUIRED_MODULES.items():
|
|
62
|
+
item = _module_status(name, module_finder)
|
|
63
|
+
item["purpose"] = purpose
|
|
64
|
+
required.append(item)
|
|
65
|
+
|
|
66
|
+
jq_path = command_finder("jq")
|
|
67
|
+
required.append(
|
|
68
|
+
{
|
|
69
|
+
"name": "jq",
|
|
70
|
+
"kind": "command",
|
|
71
|
+
"available": jq_path is not None,
|
|
72
|
+
"path": jq_path,
|
|
73
|
+
"purpose": "structural shape lookup helpers",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
optional = {}
|
|
78
|
+
for capability, modules in OPTIONAL_CAPABILITIES.items():
|
|
79
|
+
checks = [_module_status(name, module_finder) for name in modules]
|
|
80
|
+
optional[capability] = {
|
|
81
|
+
"available": all(check["available"] for check in checks),
|
|
82
|
+
"dependencies": checks,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
office_path = command_finder("soffice") or command_finder("libreoffice")
|
|
86
|
+
optional["formula_baking"] = {
|
|
87
|
+
"available": office_path is not None,
|
|
88
|
+
"dependencies": [
|
|
89
|
+
{
|
|
90
|
+
"name": "libreoffice",
|
|
91
|
+
"kind": "command",
|
|
92
|
+
"available": office_path is not None,
|
|
93
|
+
"path": office_path,
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
missing = [item["name"] for item in required if not item["available"]]
|
|
99
|
+
outcome = "dependency_missing" if missing else "ready"
|
|
100
|
+
return {
|
|
101
|
+
"schema_version": "1.0.0",
|
|
102
|
+
"run_outcome": outcome,
|
|
103
|
+
"exit_code": outcome_exit_code(outcome),
|
|
104
|
+
"required": required,
|
|
105
|
+
"optional_capabilities": optional,
|
|
106
|
+
"missing_required": missing,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main(argv=None):
|
|
111
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
112
|
+
parser.add_argument(
|
|
113
|
+
"--json",
|
|
114
|
+
action="store_true",
|
|
115
|
+
help="emit compact JSON instead of indented JSON",
|
|
116
|
+
)
|
|
117
|
+
args = parser.parse_args(argv)
|
|
118
|
+
report = diagnose()
|
|
119
|
+
if args.json:
|
|
120
|
+
print(json.dumps(report, sort_keys=True, separators=(",", ":")))
|
|
121
|
+
else:
|
|
122
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
123
|
+
return report["exit_code"]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Relocatable import bootstrap for scripts shipped inside the npm package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BootstrapError(RuntimeError):
|
|
10
|
+
"""Raised when an entry point cannot locate the shipped skills tree."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_skills_root(entry_file: str | Path) -> Path:
|
|
14
|
+
"""Find ``skills/`` relative to an entry point, without using the cwd."""
|
|
15
|
+
entry = Path(entry_file).resolve()
|
|
16
|
+
for parent in entry.parents:
|
|
17
|
+
if parent.name == "skills" and (parent / "_shared").is_dir():
|
|
18
|
+
return parent
|
|
19
|
+
candidate = parent / "skills"
|
|
20
|
+
if (candidate / "_shared").is_dir():
|
|
21
|
+
return candidate
|
|
22
|
+
raise BootstrapError(f"cannot locate the shipped skills directory from {entry.name}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bootstrap_shared(entry_file: str | Path) -> Path:
|
|
26
|
+
"""Put the shipped shared module directory on ``sys.path`` and return skills root."""
|
|
27
|
+
skills_root = find_skills_root(entry_file)
|
|
28
|
+
shared_root = skills_root / "_shared"
|
|
29
|
+
shared_text = str(shared_root)
|
|
30
|
+
if shared_text not in sys.path:
|
|
31
|
+
sys.path.insert(0, shared_text)
|
|
32
|
+
return skills_root
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared deterministic runtime primitives for pi-steel skills."""
|
|
2
|
+
|
|
3
|
+
from .cli import StageArgumentParser, package_version, publish_failure_diagnostic
|
|
4
|
+
from .contracts import (
|
|
5
|
+
ESTIMATE_PACKAGE_VERSION,
|
|
6
|
+
ITEM_INTENTS,
|
|
7
|
+
NEST_RESULT_VERSION,
|
|
8
|
+
estimate_input_hash,
|
|
9
|
+
instance_ids,
|
|
10
|
+
item_id_for,
|
|
11
|
+
placement_ids,
|
|
12
|
+
)
|
|
13
|
+
from .run_manifest import (
|
|
14
|
+
ARTIFACT_READINESS,
|
|
15
|
+
OUTCOME_EXIT_CODES,
|
|
16
|
+
PACKAGE_STATUSES,
|
|
17
|
+
RUN_OUTCOMES,
|
|
18
|
+
ManifestError,
|
|
19
|
+
RunPublisher,
|
|
20
|
+
canonical_json_bytes,
|
|
21
|
+
outcome_exit_code,
|
|
22
|
+
sha256_bytes,
|
|
23
|
+
sha256_file,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ARTIFACT_READINESS",
|
|
28
|
+
"ESTIMATE_PACKAGE_VERSION",
|
|
29
|
+
"ITEM_INTENTS",
|
|
30
|
+
"NEST_RESULT_VERSION",
|
|
31
|
+
"OUTCOME_EXIT_CODES",
|
|
32
|
+
"PACKAGE_STATUSES",
|
|
33
|
+
"RUN_OUTCOMES",
|
|
34
|
+
"ManifestError",
|
|
35
|
+
"RunPublisher",
|
|
36
|
+
"StageArgumentParser",
|
|
37
|
+
"canonical_json_bytes",
|
|
38
|
+
"estimate_input_hash",
|
|
39
|
+
"instance_ids",
|
|
40
|
+
"item_id_for",
|
|
41
|
+
"placement_ids",
|
|
42
|
+
"outcome_exit_code",
|
|
43
|
+
"package_version",
|
|
44
|
+
"publish_failure_diagnostic",
|
|
45
|
+
"sha256_bytes",
|
|
46
|
+
"sha256_file",
|
|
47
|
+
]
|