@luizsantiago/spec-guardrails 3.0.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/LICENSE +21 -0
- package/README.md +206 -0
- package/index.js +335 -0
- package/lib/archive.js +208 -0
- package/lib/assets.js +145 -0
- package/lib/brownfield.js +446 -0
- package/lib/config.js +293 -0
- package/lib/constants.js +262 -0
- package/lib/cursorrules.js +92 -0
- package/lib/delta-merge.js +248 -0
- package/lib/doctor.js +343 -0
- package/lib/download.js +133 -0
- package/lib/feature.js +272 -0
- package/lib/fs-utils.js +114 -0
- package/lib/gates.js +138 -0
- package/lib/install.js +140 -0
- package/lib/memory.js +34 -0
- package/lib/next-steps.js +50 -0
- package/lib/presets.js +176 -0
- package/lib/project-rules.js +210 -0
- package/lib/specs-utils.js +117 -0
- package/lib/token-cost.js +124 -0
- package/package.json +46 -0
- package/rules/engineering-baseline.mdc +56 -0
- package/scripts/_common.py +356 -0
- package/scripts/analyze_artifacts.py +187 -0
- package/scripts/check_commit.py +140 -0
- package/scripts/lessons.py +447 -0
- package/scripts/loop_plan.py +217 -0
- package/scripts/validate_spec.py +345 -0
- package/scripts/validate_state.py +385 -0
- package/scripts/validate_tasks.py +379 -0
- package/skills/agent-architecture.md +221 -0
- package/skills/appsec.md +83 -0
- package/skills/code-simplify.md +49 -0
- package/skills/engineering-standards.md +98 -0
- package/skills/git-handoff.md +213 -0
- package/skills/qa-strategy.md +83 -0
- package/skills/references/analyze.md +56 -0
- package/skills/references/archive.md +60 -0
- package/skills/references/constitution.md +66 -0
- package/skills/references/context-limits.md +73 -0
- package/skills/references/converge.md +47 -0
- package/skills/references/design.md +88 -0
- package/skills/references/discuss.md +68 -0
- package/skills/references/explore.md +61 -0
- package/skills/references/implement.md +175 -0
- package/skills/references/lessons.md +71 -0
- package/skills/references/memory.md +98 -0
- package/skills/references/project-init.md +62 -0
- package/skills/references/quick-mode.md +84 -0
- package/skills/references/specify.md +144 -0
- package/skills/references/sub-agents.md +117 -0
- package/skills/references/tasks.md +178 -0
- package/skills/references/validate.md +210 -0
- package/skills/security-review.md +120 -0
- package/skills/ship-ready.md +50 -0
- package/skills/task-graph-engineering.md +180 -0
- package/templates/GETTING_STARTED.md +61 -0
- package/templates/config.yaml.example +28 -0
- package/templates/presets/default.yaml +16 -0
- package/templates/presets/node-ts.yaml +22 -0
- package/templates/presets/python.yaml +22 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Completion gate for a feature directory under `.specs/features/`.
|
|
3
|
+
|
|
4
|
+
Run before declaring a feature done:
|
|
5
|
+
|
|
6
|
+
python3 validate_state.py .specs/features/auth
|
|
7
|
+
python3 validate_state.py auth
|
|
8
|
+
python3 validate_state.py # when the project has a single feature
|
|
9
|
+
|
|
10
|
+
Checks:
|
|
11
|
+
* spec.md exists
|
|
12
|
+
* validation.md exists and was written by the independent verifier
|
|
13
|
+
* the verdict is filled and reads PASS (preamble or ## Verdict heading only)
|
|
14
|
+
* the report cites file:line evidence (evidence-or-zero)
|
|
15
|
+
* every spec requirement ID has test evidence on the same coverage line
|
|
16
|
+
* the discrimination sensor result is recorded (blocking on Medium+ features)
|
|
17
|
+
* PASS with a surviving mutant fails; Medium+ PASS requires at least one kill
|
|
18
|
+
* PASS with open Gaps or a failing Security Review result fails
|
|
19
|
+
* open task checkboxes in tasks.md block completion
|
|
20
|
+
|
|
21
|
+
Exit codes: 0 pass, 1 blocking issues, 2 usage error.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from _common import (
|
|
32
|
+
Report,
|
|
33
|
+
find_placeholders,
|
|
34
|
+
mask_fenced_blocks,
|
|
35
|
+
requirement_ids,
|
|
36
|
+
resolve_feature_dir,
|
|
37
|
+
section_body,
|
|
38
|
+
visible_markdown,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
GATE = "validate-state"
|
|
42
|
+
|
|
43
|
+
VERDICT = re.compile(
|
|
44
|
+
r"^\s*[-*]?\s*\*{0,2}(?:verdict|result|status)\*{0,2}\s*:\s*\*{0,2}(?P<value>[A-Za-z ]+)",
|
|
45
|
+
re.IGNORECASE | re.MULTILINE,
|
|
46
|
+
)
|
|
47
|
+
VERDICT_HEADING = re.compile(
|
|
48
|
+
r"^#{1,6}\s*(?:verdict|result|status)\s*$\s*\n+\s*\*{0,2}(?P<value>[A-Za-z ]+)",
|
|
49
|
+
re.IGNORECASE | re.MULTILINE,
|
|
50
|
+
)
|
|
51
|
+
SECTION_START = re.compile(r"^#{2,6}\s", re.MULTILINE)
|
|
52
|
+
EVIDENCE = re.compile(r"[\w./\\-]+\.[A-Za-z][A-Za-z0-9]{0,9}:\d{1,6}\b")
|
|
53
|
+
URL = re.compile(r"\b[a-z][a-z0-9+.-]*://\S+", re.IGNORECASE)
|
|
54
|
+
SENSOR = re.compile(r"(discrimination sensor|mutant)", re.IGNORECASE)
|
|
55
|
+
# Outcome words only — the section title "Discrimination Sensor" must not count.
|
|
56
|
+
SENSOR_RESULT = re.compile(r"\b(killed|survived|injected)\b", re.IGNORECASE)
|
|
57
|
+
SENSOR_KILLED = re.compile(r"\bkilled\b", re.IGNORECASE)
|
|
58
|
+
SENSOR_SURVIVED = re.compile(r"\bsurvived\b", re.IGNORECASE)
|
|
59
|
+
SENSOR_SECTION = re.compile(
|
|
60
|
+
r"^(?P<level>#{2,6})\s*(?:Discrimination Sensor|Mutants?)\b",
|
|
61
|
+
re.MULTILINE | re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
GAPS_SECTION = re.compile(
|
|
64
|
+
r"^(?P<level>#{2,6})\s*Gaps?\b",
|
|
65
|
+
re.MULTILINE | re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
SECURITY_SECTION = re.compile(
|
|
68
|
+
r"^(?P<level>#{2,6})\s*Security Review\b",
|
|
69
|
+
re.MULTILINE | re.IGNORECASE,
|
|
70
|
+
)
|
|
71
|
+
SECURITY_FAIL = re.compile(
|
|
72
|
+
r"^\s*[-*]?\s*\*{0,2}Result\*{0,2}\s*:\s*\*{0,2}(?P<value>fail|failed|failing)\b",
|
|
73
|
+
re.IGNORECASE | re.MULTILINE,
|
|
74
|
+
)
|
|
75
|
+
SECTION_HEADING = re.compile(r"^(?P<level>#{1,6})\s", re.MULTILINE)
|
|
76
|
+
OPEN_TASK = re.compile(r"^\s*[-*]\s*\[ \]\s+(?P<label>.+)$", re.MULTILINE)
|
|
77
|
+
TASK_HEADING = re.compile(
|
|
78
|
+
r"^#{2,6}\s*T\d{1,6}\b", re.MULTILINE | re.IGNORECASE
|
|
79
|
+
)
|
|
80
|
+
PHASE_HEADING = re.compile(
|
|
81
|
+
r"^#{1,6}\s*Phase\s+\d+\b", re.MULTILINE | re.IGNORECASE
|
|
82
|
+
)
|
|
83
|
+
REQUIREMENT_REF = re.compile(r"\b[A-Z][A-Z0-9]{1,9}-\d{2,4}\b")
|
|
84
|
+
# evidence-or-zero requires a test path, not an arbitrary file:line such as config.yaml:12
|
|
85
|
+
TEST_EVIDENCE = re.compile(
|
|
86
|
+
r"(?:^|/)(?:tests?|__tests__|spec)(?:/|$)|[._-](?:test|spec)\.|test_[^/]+\.",
|
|
87
|
+
re.IGNORECASE,
|
|
88
|
+
)
|
|
89
|
+
PASS_VERDICTS = {"PASS", "PASSED"}
|
|
90
|
+
FAIL_VERDICTS = {"FAIL", "FAILED"}
|
|
91
|
+
MEDIUM_TASK_FLOOR = 4
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validation_preamble(text: str) -> str:
|
|
95
|
+
"""Return the text before the first `##` section (fences already ignored)."""
|
|
96
|
+
|
|
97
|
+
visible = visible_markdown(text)
|
|
98
|
+
match = SECTION_START.search(visible)
|
|
99
|
+
if not match:
|
|
100
|
+
return visible
|
|
101
|
+
return visible[: match.start()]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def find_verdict(text: str) -> re.Match[str] | None:
|
|
105
|
+
"""Accept Verdict only in the preamble or as a dedicated ## Verdict heading."""
|
|
106
|
+
|
|
107
|
+
preamble = validation_preamble(text)
|
|
108
|
+
return VERDICT.search(preamble) or VERDICT_HEADING.search(visible_markdown(text))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def verdict_conflict(text: str) -> tuple[str, str] | None:
|
|
112
|
+
"""Return (preamble, heading) values when both exist and disagree."""
|
|
113
|
+
|
|
114
|
+
preamble = validation_preamble(text)
|
|
115
|
+
preamble_match = VERDICT.search(preamble)
|
|
116
|
+
heading_match = VERDICT_HEADING.search(visible_markdown(text))
|
|
117
|
+
if not preamble_match or not heading_match:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
left = re.sub(r"\s+", " ", preamble_match.group("value").strip().upper())
|
|
121
|
+
right = re.sub(r"\s+", " ", heading_match.group("value").strip().upper())
|
|
122
|
+
if left == right:
|
|
123
|
+
return None
|
|
124
|
+
return left, right
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sensor_focus(text: str) -> str:
|
|
128
|
+
"""Text where mutant outcomes must appear: sensor sections and mutant lines."""
|
|
129
|
+
|
|
130
|
+
visible = visible_markdown(text)
|
|
131
|
+
chunks: list[str] = []
|
|
132
|
+
|
|
133
|
+
for match in SENSOR_SECTION.finditer(visible):
|
|
134
|
+
level = len(match.group("level"))
|
|
135
|
+
start = match.end()
|
|
136
|
+
end = len(visible)
|
|
137
|
+
for heading in SECTION_HEADING.finditer(visible, start):
|
|
138
|
+
if len(heading.group("level")) <= level:
|
|
139
|
+
end = heading.start()
|
|
140
|
+
break
|
|
141
|
+
chunks.append(visible[match.start() : end])
|
|
142
|
+
|
|
143
|
+
for line in visible.splitlines():
|
|
144
|
+
if SENSOR.search(line):
|
|
145
|
+
chunks.append(line)
|
|
146
|
+
|
|
147
|
+
return "\n".join(chunks)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def find_evidence(text: str) -> list[str]:
|
|
151
|
+
"""Return test file:line references, ignoring fences, comments, URLs, and ports."""
|
|
152
|
+
|
|
153
|
+
visible = visible_markdown(text)
|
|
154
|
+
hits = EVIDENCE.findall(URL.sub(" ", visible))
|
|
155
|
+
return [hit for hit in hits if TEST_EVIDENCE.search(hit.replace("\\", "/"))]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def open_gap_lines(text: str) -> list[str]:
|
|
159
|
+
"""Return non-empty Gaps bullets that are not an explicit none placeholder."""
|
|
160
|
+
|
|
161
|
+
body = section_body(visible_markdown(text), GAPS_SECTION)
|
|
162
|
+
if body is None:
|
|
163
|
+
return []
|
|
164
|
+
gaps: list[str] = []
|
|
165
|
+
none_values = {
|
|
166
|
+
"none",
|
|
167
|
+
"n/a",
|
|
168
|
+
"na",
|
|
169
|
+
"-",
|
|
170
|
+
"—",
|
|
171
|
+
"–",
|
|
172
|
+
"no gaps",
|
|
173
|
+
}
|
|
174
|
+
for raw in body.splitlines():
|
|
175
|
+
line = raw.strip()
|
|
176
|
+
marker = re.match(r"^[-*]\s+(.*)$", line)
|
|
177
|
+
if not marker:
|
|
178
|
+
continue
|
|
179
|
+
# Do not use lstrip("-* ") — it eats emphasis markers on `**none**`.
|
|
180
|
+
cleaned = marker.group(1).strip().strip("`")
|
|
181
|
+
while len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in "*_":
|
|
182
|
+
cleaned = cleaned[1:-1].strip()
|
|
183
|
+
if not cleaned or cleaned.lower() in none_values:
|
|
184
|
+
continue
|
|
185
|
+
gaps.append(cleaned)
|
|
186
|
+
return gaps
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def security_blocks_pass(text: str) -> bool:
|
|
190
|
+
"""True when Security Review explicitly records a failing result."""
|
|
191
|
+
|
|
192
|
+
body = section_body(visible_markdown(text), SECURITY_SECTION)
|
|
193
|
+
if body is None:
|
|
194
|
+
return False
|
|
195
|
+
return bool(SECURITY_FAIL.search(body))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def is_medium_plus(feature_dir: Path) -> bool:
|
|
199
|
+
"""Medium+ when a non-empty design exists, tasks are substantial, or work is phased."""
|
|
200
|
+
|
|
201
|
+
design_path = feature_dir / "design.md"
|
|
202
|
+
if design_path.is_file() and design_path.read_text(encoding="utf-8").strip():
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
tasks_path = feature_dir / "tasks.md"
|
|
206
|
+
if not tasks_path.is_file():
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
tasks = mask_fenced_blocks(tasks_path.read_text(encoding="utf-8"))
|
|
210
|
+
task_count = len(TASK_HEADING.findall(tasks))
|
|
211
|
+
phase_count = len(PHASE_HEADING.findall(tasks))
|
|
212
|
+
return task_count >= MEDIUM_TASK_FLOOR or phase_count >= 2
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def requirement_evidence_gaps(spec_text: str, validation: str) -> list[str]:
|
|
216
|
+
"""Return requirement IDs that lack a same-line test evidence citation."""
|
|
217
|
+
|
|
218
|
+
missing: list[str] = []
|
|
219
|
+
visible = visible_markdown(validation)
|
|
220
|
+
for requirement_id in requirement_ids(visible_markdown(spec_text)):
|
|
221
|
+
covered = False
|
|
222
|
+
for line in visible.splitlines():
|
|
223
|
+
if requirement_id not in line:
|
|
224
|
+
continue
|
|
225
|
+
if find_evidence(line):
|
|
226
|
+
covered = True
|
|
227
|
+
break
|
|
228
|
+
if not covered:
|
|
229
|
+
missing.append(requirement_id)
|
|
230
|
+
return missing
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def build_report(feature_dir: Path) -> Report:
|
|
234
|
+
report = Report(gate=GATE, target=str(feature_dir))
|
|
235
|
+
|
|
236
|
+
spec_path = feature_dir / "spec.md"
|
|
237
|
+
spec_text = ""
|
|
238
|
+
if spec_path.exists() and spec_path.read_text(encoding="utf-8").strip():
|
|
239
|
+
report.ok("spec.md present")
|
|
240
|
+
spec_text = spec_path.read_text(encoding="utf-8")
|
|
241
|
+
else:
|
|
242
|
+
report.error("spec.md missing or empty - a feature cannot close without a spec")
|
|
243
|
+
|
|
244
|
+
validation_path = feature_dir / "validation.md"
|
|
245
|
+
if not validation_path.exists():
|
|
246
|
+
report.error(
|
|
247
|
+
"validation.md missing - run /verify with an independent verifier "
|
|
248
|
+
"before closing the feature"
|
|
249
|
+
)
|
|
250
|
+
return report
|
|
251
|
+
|
|
252
|
+
validation = validation_path.read_text(encoding="utf-8")
|
|
253
|
+
if not validation.strip():
|
|
254
|
+
report.error("validation.md is empty")
|
|
255
|
+
return report
|
|
256
|
+
|
|
257
|
+
verdict: str | None = None
|
|
258
|
+
conflict = verdict_conflict(validation)
|
|
259
|
+
if conflict:
|
|
260
|
+
left, right = conflict
|
|
261
|
+
report.error(
|
|
262
|
+
f"conflicting verdicts: preamble says '{left}' but ## Verdict says '{right}'"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
verdict_match = find_verdict(validation)
|
|
266
|
+
if not verdict_match:
|
|
267
|
+
report.error(
|
|
268
|
+
"validation.md has no verdict - add 'Verdict: PASS' in the preamble "
|
|
269
|
+
"(before ## sections) or a '## Verdict' heading"
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
verdict = re.sub(r"\s+", " ", verdict_match.group("value").strip().upper())
|
|
273
|
+
if verdict in PASS_VERDICTS:
|
|
274
|
+
report.ok("verifier verdict is PASS")
|
|
275
|
+
elif verdict in FAIL_VERDICTS:
|
|
276
|
+
report.error("verifier verdict is FAIL - resolve gaps and re-verify")
|
|
277
|
+
else:
|
|
278
|
+
report.error(
|
|
279
|
+
f"verifier verdict is not PASS: '{verdict}' - "
|
|
280
|
+
"write PASS only with no remaining gaps"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
evidence = find_evidence(validation)
|
|
284
|
+
if evidence:
|
|
285
|
+
report.ok(f"{len(evidence)} file:line evidence reference(s)")
|
|
286
|
+
else:
|
|
287
|
+
report.error(
|
|
288
|
+
"no file:line evidence found - evidence-or-zero requires test references "
|
|
289
|
+
"such as test/auth/token.test.ts:41 (a URL is not evidence)"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
if spec_text.strip():
|
|
293
|
+
gaps = requirement_evidence_gaps(spec_text, validation)
|
|
294
|
+
if gaps:
|
|
295
|
+
for requirement_id in gaps:
|
|
296
|
+
report.error(
|
|
297
|
+
f"{requirement_id} has no test file:line on the same coverage line"
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
report.ok("every spec requirement has test evidence")
|
|
301
|
+
|
|
302
|
+
focus = sensor_focus(validation)
|
|
303
|
+
if verdict in PASS_VERDICTS and SENSOR_SURVIVED.search(focus):
|
|
304
|
+
report.error(
|
|
305
|
+
"verdict is PASS but a mutant survived - kill every mutant before closing"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
medium_plus = is_medium_plus(feature_dir)
|
|
309
|
+
has_sensor = bool(SENSOR.search(validation) and SENSOR_RESULT.search(focus))
|
|
310
|
+
if has_sensor:
|
|
311
|
+
if (
|
|
312
|
+
medium_plus
|
|
313
|
+
and verdict in PASS_VERDICTS
|
|
314
|
+
and not SENSOR_KILLED.search(focus)
|
|
315
|
+
):
|
|
316
|
+
report.error(
|
|
317
|
+
"Medium+ PASS requires at least one killed mutant "
|
|
318
|
+
"(injected alone is not enough to close)"
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
report.ok("discrimination sensor result recorded")
|
|
322
|
+
elif medium_plus:
|
|
323
|
+
report.error(
|
|
324
|
+
"Medium+ feature requires a discrimination sensor result "
|
|
325
|
+
"(mutant injected/killed/survived) before closing"
|
|
326
|
+
)
|
|
327
|
+
else:
|
|
328
|
+
report.warn(
|
|
329
|
+
"no discrimination sensor section found - confirm mutants were injected"
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if verdict in PASS_VERDICTS:
|
|
333
|
+
for gap in open_gap_lines(validation)[:10]:
|
|
334
|
+
report.error(
|
|
335
|
+
f"verdict is PASS but Gaps still lists '{gap}' - "
|
|
336
|
+
"resolve gaps or write FAIL"
|
|
337
|
+
)
|
|
338
|
+
if security_blocks_pass(validation):
|
|
339
|
+
report.error(
|
|
340
|
+
"verdict is PASS but Security Review result is fail - "
|
|
341
|
+
"resolve findings or write FAIL"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
tasks_path = feature_dir / "tasks.md"
|
|
345
|
+
if tasks_path.exists():
|
|
346
|
+
open_tasks = OPEN_TASK.findall(
|
|
347
|
+
mask_fenced_blocks(tasks_path.read_text(encoding="utf-8"))
|
|
348
|
+
)
|
|
349
|
+
if open_tasks:
|
|
350
|
+
for label in open_tasks[:10]:
|
|
351
|
+
report.error(f"open task remains: {label.strip()}")
|
|
352
|
+
else:
|
|
353
|
+
report.ok("all tasks are checked off")
|
|
354
|
+
|
|
355
|
+
placeholders = find_placeholders(validation)
|
|
356
|
+
if placeholders:
|
|
357
|
+
for item in placeholders[:10]:
|
|
358
|
+
report.error(f"unresolved placeholder in validation.md at {item}")
|
|
359
|
+
|
|
360
|
+
return report
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def main(argv: list[str] | None = None) -> int:
|
|
364
|
+
parser = argparse.ArgumentParser(
|
|
365
|
+
description="Validate that a feature is ready to be declared done"
|
|
366
|
+
)
|
|
367
|
+
parser.add_argument(
|
|
368
|
+
"feature",
|
|
369
|
+
nargs="?",
|
|
370
|
+
help="feature name or path to .specs/features/[feature]",
|
|
371
|
+
)
|
|
372
|
+
parser.add_argument(
|
|
373
|
+
"--strict",
|
|
374
|
+
action="store_true",
|
|
375
|
+
help="treat warnings as blocking failures",
|
|
376
|
+
)
|
|
377
|
+
args = parser.parse_args(argv)
|
|
378
|
+
|
|
379
|
+
feature_dir = resolve_feature_dir(args.feature, GATE)
|
|
380
|
+
report = build_report(feature_dir)
|
|
381
|
+
return report.emit(strict=args.strict)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
if __name__ == "__main__":
|
|
385
|
+
sys.exit(main())
|