|
38 | 38 |
|
39 | 39 | from __future__ import annotations |
40 | 40 |
|
| 41 | +import json |
| 42 | +import os |
41 | 43 | import re |
42 | 44 | import sys |
43 | | -from typing import TYPE_CHECKING |
| 45 | +from typing import TYPE_CHECKING, Any |
44 | 46 |
|
45 | 47 | if TYPE_CHECKING: |
46 | 48 | from .cfg import Instr |
@@ -403,93 +405,176 @@ def cmd_ownir(path: str, fmt: str = "human", severity: str = "error", |
403 | 405 | return 1 if leaks else 0 |
404 | 406 |
|
405 | 407 |
|
406 | | -def cmd_own_fix(rest: list[str]) -> int: |
407 | | - """`own-fix subscriptions candidates <facts.json> --config <own.toml> |
408 | | - --class <FQN> [--finding-id <ID>]... --output <candidates.json> [--root <dir>]`.""" |
409 | | - import json |
410 | | - |
411 | | - usage = ( |
412 | | - "usage: python -m ownlang own-fix subscriptions candidates <facts.json> " |
413 | | - "--config <own.toml> --class <FQN> [--finding-id <ID>]... " |
414 | | - "--output <candidates.json> [--root <dir>]" |
415 | | - ) |
416 | | - if len(rest) < 2 or rest[0] != "subscriptions" or rest[1] != "candidates": |
417 | | - print(usage, file=sys.stderr) |
418 | | - return 2 |
419 | | - |
420 | | - args = rest[2:] |
421 | | - facts_path: str | None = None |
422 | | - config_path: str | None = None |
423 | | - class_fqn: str | None = None |
424 | | - output: str | None = None |
425 | | - root = "." |
426 | | - finding_ids: list[str] = [] |
| 408 | +def _own_fix_parse( |
| 409 | + args: list[str], flags: set[str], multi: set[str] |
| 410 | +) -> tuple[list[str], dict[str, Any]] | None: |
| 411 | + """Split `--flag value` (flags/multi) from positionals; None on any arg error.""" |
| 412 | + positional: list[str] = [] |
| 413 | + opts: dict[str, Any] = {m: [] for m in multi} |
427 | 414 | i = 0 |
428 | 415 | while i < len(args): |
429 | 416 | a = args[i] |
430 | 417 | if a.startswith("--"): |
| 418 | + if a not in flags and a not in multi: |
| 419 | + print(f"own-fix: unknown flag {a}", file=sys.stderr) |
| 420 | + return None |
431 | 421 | if i + 1 >= len(args): |
432 | 422 | print(f"own-fix: {a} requires a value", file=sys.stderr) |
433 | | - return 2 |
434 | | - value = args[i + 1] |
435 | | - i += 2 |
436 | | - if a == "--config": |
437 | | - config_path = value |
438 | | - elif a == "--class": |
439 | | - class_fqn = value |
440 | | - elif a == "--output": |
441 | | - output = value |
442 | | - elif a == "--root": |
443 | | - root = value |
444 | | - elif a == "--finding-id": |
445 | | - finding_ids.append(value) |
| 423 | + return None |
| 424 | + if a in multi: |
| 425 | + opts[a].append(args[i + 1]) |
446 | 426 | else: |
447 | | - print(f"own-fix: unknown flag {a}", file=sys.stderr) |
448 | | - return 2 |
449 | | - elif facts_path is None: |
450 | | - facts_path = a |
451 | | - i += 1 |
| 427 | + opts[a] = args[i + 1] |
| 428 | + i += 2 |
452 | 429 | else: |
453 | | - print(f"own-fix: unexpected argument {a!r}", file=sys.stderr) |
454 | | - return 2 |
| 430 | + positional.append(a) |
| 431 | + i += 1 |
| 432 | + return positional, opts |
455 | 433 |
|
456 | | - if not (facts_path and config_path and class_fqn and output): |
457 | | - print( |
458 | | - "own-fix: a facts.json, --config, --class and --output are all required", |
459 | | - file=sys.stderr, |
460 | | - ) |
461 | | - print(usage, file=sys.stderr) |
| 434 | + |
| 435 | +def _read_json(path: str) -> Any: |
| 436 | + with open(path, encoding="utf-8") as fh: |
| 437 | + return json.load(fh) |
| 438 | + |
| 439 | + |
| 440 | +def _write_atomic(path: str, obj: Any) -> int: |
| 441 | + """Write only after the object is fully built; a bad path is a clean exit 2.""" |
| 442 | + tmp = f"{path}.tmp" |
| 443 | + try: |
| 444 | + with open(tmp, "w", encoding="utf-8") as fh: |
| 445 | + fh.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n") |
| 446 | + os.replace(tmp, path) |
| 447 | + except OSError as exc: |
| 448 | + print(f"own-fix: cannot write {path}: {exc}", file=sys.stderr) |
| 449 | + return 2 |
| 450 | + return 0 |
| 451 | + |
| 452 | + |
| 453 | +def _cmd_candidates(rest: list[str]) -> int: |
| 454 | + parsed = _own_fix_parse( |
| 455 | + rest, {"--config", "--class", "--output", "--root"}, {"--finding-id"} |
| 456 | + ) |
| 457 | + if parsed is None: |
| 458 | + return 2 |
| 459 | + positional, opts = parsed |
| 460 | + if (len(positional) != 1 or not opts.get("--config") |
| 461 | + or not opts.get("--class") or not opts.get("--output")): |
| 462 | + print("usage: own-fix subscriptions candidates <facts.json> --config <own.toml> " |
| 463 | + "--class <FQN> [--finding-id <ID>]... --output <candidates.json> [--root <dir>]", |
| 464 | + file=sys.stderr) |
462 | 465 | return 2 |
463 | 466 |
|
464 | 467 | from ownlang.config import ConfigError, load_target_subscribe |
465 | 468 | from ownlang.fix_candidates import CollectError, collect_candidates |
466 | 469 |
|
467 | 470 | try: |
468 | | - target = load_target_subscribe(config_path) |
| 471 | + target = load_target_subscribe(opts["--config"]) |
469 | 472 | except ConfigError as exc: |
470 | 473 | print(f"own-fix: {exc}", file=sys.stderr) |
471 | 474 | return 2 |
472 | 475 | try: |
473 | | - with open(facts_path, encoding="utf-8") as fh: |
474 | | - facts = json.load(fh) |
| 476 | + facts = _read_json(positional[0]) |
475 | 477 | except (OSError, ValueError) as exc: |
476 | | - print(f"own-fix: cannot read facts {facts_path}: {exc}", file=sys.stderr) |
| 478 | + print(f"own-fix: cannot read facts {positional[0]}: {exc}", file=sys.stderr) |
477 | 479 | return 2 |
478 | | - |
479 | 480 | try: |
480 | | - envelope = collect_candidates(facts, target, class_fqn, finding_ids or None, root) |
| 481 | + envelope = collect_candidates( |
| 482 | + facts, target, opts["--class"], opts["--finding-id"] or None, opts.get("--root", ".") |
| 483 | + ) |
481 | 484 | except CollectError as exc: |
482 | 485 | print(f"own-fix: {exc}", file=sys.stderr) |
483 | 486 | return 2 |
| 487 | + rc = _write_atomic(opts["--output"], envelope) |
| 488 | + if rc == 0: |
| 489 | + print(f"own-fix: wrote {len(envelope['candidates'])} candidate(s) -> {opts['--output']}") |
| 490 | + return rc |
| 491 | + |
484 | 492 |
|
| 493 | +def _cmd_render(rest: list[str]) -> int: |
| 494 | + parsed = _own_fix_parse(rest, {"--prompt", "--schema"}, set()) |
| 495 | + if parsed is None: |
| 496 | + return 2 |
| 497 | + positional, opts = parsed |
| 498 | + if len(positional) != 1 or not opts.get("--prompt") or not opts.get("--schema"): |
| 499 | + print("usage: own-fix subscriptions render <candidates.json> --prompt <prompt.txt> " |
| 500 | + "--schema <fix-plan.schema.json>", file=sys.stderr) |
| 501 | + return 2 |
| 502 | + |
| 503 | + from ownlang.fix_candidates import CollectError |
| 504 | + from ownlang.fix_plan import render |
| 505 | + |
| 506 | + try: |
| 507 | + bundle = _read_json(positional[0]) |
| 508 | + except (OSError, ValueError) as exc: |
| 509 | + print(f"own-fix: cannot read candidates {positional[0]}: {exc}", file=sys.stderr) |
| 510 | + return 2 |
485 | 511 | try: |
486 | | - with open(output, "w", encoding="utf-8") as fh: |
487 | | - fh.write(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n") |
| 512 | + prompt, schema = render(bundle) |
| 513 | + except CollectError as exc: |
| 514 | + print(f"own-fix: {exc}", file=sys.stderr) |
| 515 | + return 2 |
| 516 | + try: |
| 517 | + with open(opts["--prompt"], "w", encoding="utf-8") as fh: |
| 518 | + fh.write(prompt) |
488 | 519 | except OSError as exc: |
489 | | - print(f"own-fix: cannot write {output}: {exc}", file=sys.stderr) |
| 520 | + print(f"own-fix: cannot write {opts['--prompt']}: {exc}", file=sys.stderr) |
490 | 521 | return 2 |
491 | | - print(f"own-fix: wrote {len(envelope['candidates'])} candidate(s) -> {output}") |
492 | | - return 0 |
| 522 | + rc = _write_atomic(opts["--schema"], schema) |
| 523 | + if rc == 0: |
| 524 | + count = len(bundle.get("candidates", [])) |
| 525 | + print(f"own-fix: rendered prompt + schema for {count} candidate(s)") |
| 526 | + return rc |
| 527 | + |
| 528 | + |
| 529 | +def _cmd_validate_plan(rest: list[str]) -> int: |
| 530 | + parsed = _own_fix_parse(rest, {"--output"}, set()) |
| 531 | + if parsed is None: |
| 532 | + return 2 |
| 533 | + positional, opts = parsed |
| 534 | + if len(positional) != 2 or not opts.get("--output"): |
| 535 | + print("usage: own-fix subscriptions validate-plan <candidates.json> <fix-plan.json> " |
| 536 | + "--output <validated-plan.json>", file=sys.stderr) |
| 537 | + return 2 |
| 538 | + |
| 539 | + from ownlang.fix_plan import PlanError, validate_plan |
| 540 | + |
| 541 | + try: |
| 542 | + bundle = _read_json(positional[0]) |
| 543 | + except (OSError, ValueError) as exc: |
| 544 | + print(f"own-fix: cannot read candidates {positional[0]}: {exc}", file=sys.stderr) |
| 545 | + return 2 |
| 546 | + try: |
| 547 | + plan = _read_json(positional[1]) |
| 548 | + except (OSError, ValueError) as exc: |
| 549 | + print(f"own-fix: cannot read fix-plan {positional[1]}: {exc}", file=sys.stderr) |
| 550 | + return 2 |
| 551 | + try: |
| 552 | + validated = validate_plan(bundle, plan) |
| 553 | + except PlanError as exc: |
| 554 | + print(f"own-fix: {exc}", file=sys.stderr) |
| 555 | + return 2 |
| 556 | + rc = _write_atomic(opts["--output"], validated) |
| 557 | + if rc == 0: |
| 558 | + print(f"own-fix: validated {len(validated['decisions'])} decision(s) -> {opts['--output']}") |
| 559 | + return rc |
| 560 | + |
| 561 | + |
| 562 | +def cmd_own_fix(rest: list[str]) -> int: |
| 563 | + """`own-fix subscriptions {candidates|render|validate-plan} ...`.""" |
| 564 | + if len(rest) < 2 or rest[0] != "subscriptions": |
| 565 | + print("usage: python -m ownlang own-fix subscriptions " |
| 566 | + "{candidates|render|validate-plan} ...", file=sys.stderr) |
| 567 | + return 2 |
| 568 | + verb, args = rest[1], rest[2:] |
| 569 | + if verb == "candidates": |
| 570 | + return _cmd_candidates(args) |
| 571 | + if verb == "render": |
| 572 | + return _cmd_render(args) |
| 573 | + if verb == "validate-plan": |
| 574 | + return _cmd_validate_plan(args) |
| 575 | + print(f"own-fix: unknown subcommand {verb!r} " |
| 576 | + "(candidates | render | validate-plan)", file=sys.stderr) |
| 577 | + return 2 |
493 | 578 |
|
494 | 579 |
|
495 | 580 | _FORMATS = {"human", "github", "msbuild", "sarif", "json"} |
|
0 commit comments