|
10 | 10 | from .validator_sdtm import validate_sdtm |
11 | 11 | from .fmt import FMT_STYLE_ID, format_text, normalize_newlines |
12 | 12 | from . import __version__ as _engine_version |
| 13 | +from .ir.adapter import sans_ir_to_irdoc |
| 14 | +from .ir.schema import validate_sans_ir |
| 15 | +from .sans_script import irdoc_to_expanded_sans |
13 | 16 |
|
14 | 17 |
|
15 | 18 | def _parse_tables(tables_arg: str | None) -> set[str] | None: |
@@ -119,6 +122,22 @@ def main(argv: list[str] | None = None) -> int: |
119 | 122 | run_parser.add_argument("--lock-only", action="store_true", help="With --emit-schema-lock: generate lock only, do not execute (even if all datasources are typed)") |
120 | 123 | run_parser.add_argument("--bundle-mode", choices=["full", "thin"], default="full", help="Bundle mode: full (embed datasource bytes) or thin (fingerprints only)") |
121 | 124 |
|
| 125 | + run_ir_parser = subparsers.add_parser("run-ir", help="Execute a canonical sans.ir file") |
| 126 | + run_ir_parser.add_argument("script", help="Path to the sans.ir file") |
| 127 | + run_ir_parser.add_argument("--out", required=True, help="Output directory for plan/report and outputs") |
| 128 | + run_ir_parser.add_argument("--tables", default="", help="Comma-separated table bindings name=path.csv") |
| 129 | + run_ir_parser.add_argument("--format", default="csv", choices=["csv", "xpt"], help="Output format (csv, xpt)") |
| 130 | + run_ir_parser.add_argument("--strict", dest="strict", action="store_true", default=True) |
| 131 | + run_ir_parser.add_argument("--no-strict", dest="strict", action="store_false") |
| 132 | + run_ir_parser.add_argument("--schema-lock", metavar="path", default=None, help="Path to schema.lock.json to enforce when ingesting datasources") |
| 133 | + run_ir_parser.add_argument("--emit-schema-lock", metavar="path", default=None, help="After successful run, write schema.lock.json to this path") |
| 134 | + run_ir_parser.add_argument("--lock-only", action="store_true", help="With --emit-schema-lock: generate lock only, do not execute (even if all datasources are typed)") |
| 135 | + run_ir_parser.add_argument("--bundle-mode", choices=["full", "thin"], default="full", help="Bundle mode: full (embed datasource bytes) or thin (fingerprints only)") |
| 136 | + |
| 137 | + ir_validate_parser = subparsers.add_parser("ir-validate", help="Validate sans.ir structure only") |
| 138 | + ir_validate_parser.add_argument("script", help="Path to the sans.ir file") |
| 139 | + ir_validate_parser.add_argument("--strict", action="store_true", default=False, help="Treat structural warnings as errors") |
| 140 | + |
122 | 141 | schema_lock_parser = subparsers.add_parser("schema-lock", help="Generate schema.lock.json without execution (no --out required)") |
123 | 142 | schema_lock_parser.add_argument("script", help="Path to the script file") |
124 | 143 | schema_lock_parser.add_argument("--write", "-o", dest="write", default=None, metavar="path", help="Lock output path (default: <script_dir>/<script_stem>.schema.lock.json); relative paths resolved against script dir") |
@@ -420,6 +439,84 @@ def main(argv: list[str] | None = None) -> int: |
420 | 439 | print(f"ok: wrote schema lock to {emit_path}{suffix}") |
421 | 440 | return int(report.get("exit_code_bucket", 50)) |
422 | 441 |
|
| 442 | + if args.command == "run-ir": |
| 443 | + script_path = Path(args.script) |
| 444 | + out_dir = Path(args.out) |
| 445 | + bindings = {} |
| 446 | + if args.tables: |
| 447 | + for item in args.tables.split(","): |
| 448 | + if not item.strip(): |
| 449 | + continue |
| 450 | + if "=" not in item: |
| 451 | + return _write_failed_report(out_dir, f"Invalid table binding '{item}'") |
| 452 | + name, path = item.split("=", 1) |
| 453 | + name = name.strip() |
| 454 | + if name in bindings: |
| 455 | + return _write_failed_report(out_dir, f"Duplicate table binding for '{name}'") |
| 456 | + bindings[name] = path.strip() |
| 457 | + try: |
| 458 | + sans_ir = json.loads(script_path.read_text(encoding="utf-8")) |
| 459 | + validate_sans_ir(sans_ir) |
| 460 | + ir_doc = sans_ir_to_irdoc(sans_ir, file_name=str(script_path)) |
| 461 | + except OSError as exc: |
| 462 | + return _write_failed_report(out_dir, str(exc)) |
| 463 | + except Exception as exc: |
| 464 | + return _write_failed_report(out_dir, f"Invalid sans.ir: {exc}") |
| 465 | + |
| 466 | + # Reuse existing run path and runtime core by rendering canonical expanded.sans |
| 467 | + # from IR, then executing as a normal .sans script. |
| 468 | + expanded = irdoc_to_expanded_sans(ir_doc) |
| 469 | + virtual_script_path = script_path.with_suffix(".expanded.sans") |
| 470 | + schema_lock_path = Path(args.schema_lock) if args.schema_lock else None |
| 471 | + emit_schema_lock_path = Path(args.emit_schema_lock) if args.emit_schema_lock else None |
| 472 | + report = run_script( |
| 473 | + text=expanded, |
| 474 | + file_name=str(virtual_script_path), |
| 475 | + bindings=bindings, |
| 476 | + out_dir=out_dir, |
| 477 | + strict=args.strict, |
| 478 | + output_format=args.format, |
| 479 | + include_roots=None, |
| 480 | + allow_absolute_includes=False, |
| 481 | + allow_include_escape=False, |
| 482 | + legacy_sas=False, |
| 483 | + schema_lock_path=schema_lock_path, |
| 484 | + emit_schema_lock_path=emit_schema_lock_path, |
| 485 | + lock_only=getattr(args, "lock_only", False), |
| 486 | + bundle_mode=getattr(args, "bundle_mode", "full"), |
| 487 | + ) |
| 488 | + status = report.get("status") |
| 489 | + if status == "refused": |
| 490 | + primary = report.get("primary_error") or {} |
| 491 | + loc = primary.get("loc") or {} |
| 492 | + loc_str = f"{loc.get('file')}:{loc.get('line_start')}" if loc else "" |
| 493 | + print(f"refused: {primary.get('code')} at {loc_str}".rstrip()) |
| 494 | + elif status == "failed": |
| 495 | + primary = report.get("primary_error") or {} |
| 496 | + loc = primary.get("loc") or {} |
| 497 | + loc_str = f"{loc.get('file')}:{loc.get('line_start')}" if loc else "" |
| 498 | + print(f"failed: {primary.get('code')} at {loc_str}".rstrip()) |
| 499 | + else: |
| 500 | + print("ok: wrote plan.ir.json report.json registry.candidate.json runtime.evidence.json") |
| 501 | + return int(report.get("exit_code_bucket", 50)) |
| 502 | + |
| 503 | + if args.command == "ir-validate": |
| 504 | + script_path = Path(args.script) |
| 505 | + try: |
| 506 | + sans_ir = json.loads(script_path.read_text(encoding="utf-8")) |
| 507 | + warnings = validate_sans_ir(sans_ir, strict=bool(getattr(args, "strict", False))) |
| 508 | + except OSError as exc: |
| 509 | + print(f"invalid: {exc}", file=sys.stderr) |
| 510 | + return 2 |
| 511 | + except Exception as exc: |
| 512 | + print(f"invalid: {exc}", file=sys.stderr) |
| 513 | + return 2 |
| 514 | + if warnings: |
| 515 | + print(f"ok: valid sans.ir ({len(warnings)} warning(s))", file=sys.stderr) |
| 516 | + else: |
| 517 | + print("ok: valid sans.ir", file=sys.stderr) |
| 518 | + return 0 |
| 519 | + |
423 | 520 | if args.command == "schema-lock": |
424 | 521 | from .runtime import generate_schema_lock_standalone |
425 | 522 | script_path = Path(args.script) |
|
0 commit comments