Skip to content

Commit 139a756

Browse files
authored
Merge pull request #286: S1 invoke planner (render + validate-plan)
S1 — invoke planner: candidates.json → o7 invoke → validated plan (analysis/planning). The second increment of the subscription-autofix pipeline. Nothing edits source; the model authors only an enum action per finding and Own.NET never calls o7. candidates.json → deterministic prompt + per-candidate JSON Schema (own-fix subscriptions render) → the existing `o7 invoke` (007, closed-world, read-only-data, explicit schema) → an UNTRUSTED enum-only model plan → Own.NET bijection + permission validation (own-fix subscriptions validate-plan) → a canonical validated-plan.json Own.NET owns render + domain validation; 007 owns `o7 invoke`; `scripts/own-fix-plan.sh` is glue only; apply stays S2. The validator enforces the frozen S0 envelope (one type, one file, exact (type,file) pair, canonical paths, permission tiering — convert_acquire only for an inotify_property_changed contract), requires a full candidate↔decision bijection, and materializes target_api / selection / source_files / file / span / SHA as a canonical projection of the candidates so nothing the model authored survives. A zero-candidate bundle renders a valid `items:false` schema. Live-wired once against a real `o7 invoke` + claude. Arbiter: S1 ACCEPTED / CLOSED at 5dbeeb7. S2 (deterministic Roslyn rewriter + gates) unblocks after this merge.
2 parents a1a1d93 + 5dbeeb7 commit 139a756

9 files changed

Lines changed: 992 additions & 59 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,6 +1676,29 @@ jobs:
16761676
fi
16771677
echo "OK: own-fix collector — bundle, permission tiering, and hard rejections"
16781678
1679+
# S1 orchestration (glue): render -> o7 invoke -> validate-plan, exercised with a
1680+
# FAKE o7 that drops the canned fixture result into --out, so the shell wiring is
1681+
# tested without a live model. The pure render/validate logic + fixture conformance
1682+
# run in the "tests" job (tests/test_fix_plan.py).
1683+
- name: S1 own-fix-plan orchestration (fake o7)
1684+
run: |
1685+
fx=tests/fixtures/o7-invoke/subscription-fix-plan-v1
1686+
bin="$RUNNER_TEMP/bin"; mkdir -p "$bin"
1687+
export O7_FAKE_RESULT="$PWD/$fx/o7-result.json"
1688+
cat > "$bin/o7" <<'SH'
1689+
#!/usr/bin/env bash
1690+
out=""
1691+
while [ $# -gt 0 ]; do case "$1" in --out) out="$2"; shift 2;; *) shift;; esac; done
1692+
mkdir -p "$out"; cp "$O7_FAKE_RESULT" "$out/result.json"
1693+
SH
1694+
chmod +x "$bin/o7"
1695+
PATH="$bin:$PATH" scripts/own-fix-plan.sh "$fx/candidates.json" \
1696+
"$RUNNER_TEMP/validated.json" --o7 o7
1697+
python -c "import json,sys; a=json.load(open(sys.argv[1])); b=json.load(open(sys.argv[2])); sys.exit(0 if a==b else 1)" \
1698+
"$RUNNER_TEMP/validated.json" "$fx/expected-validated-plan.json" \
1699+
|| { echo 'FAIL: glue-produced plan != expected'; exit 1; }
1700+
echo 'OK: render -> (fake o7) -> validate produces the expected validated plan'
1701+
16791702
# The OwnTS frontend spike (P-020 Own.React): the SAME OwnIR seam, fed from a
16801703
# React .tsx instead of C#. Two analyses over the one core: (1) a useEffect
16811704
# acquire (timer / subscribe / listener) with no cleanup return is the core's

‎ownlang/__main__.py‎

Lines changed: 144 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@
3838

3939
from __future__ import annotations
4040

41+
import json
42+
import os
4143
import re
4244
import sys
43-
from typing import TYPE_CHECKING
45+
from typing import TYPE_CHECKING, Any
4446

4547
if TYPE_CHECKING:
4648
from .cfg import Instr
@@ -403,93 +405,176 @@ def cmd_ownir(path: str, fmt: str = "human", severity: str = "error",
403405
return 1 if leaks else 0
404406

405407

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}
427414
i = 0
428415
while i < len(args):
429416
a = args[i]
430417
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
431421
if i + 1 >= len(args):
432422
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])
446426
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
452429
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
455433

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)
462465
return 2
463466

464467
from ownlang.config import ConfigError, load_target_subscribe
465468
from ownlang.fix_candidates import CollectError, collect_candidates
466469

467470
try:
468-
target = load_target_subscribe(config_path)
471+
target = load_target_subscribe(opts["--config"])
469472
except ConfigError as exc:
470473
print(f"own-fix: {exc}", file=sys.stderr)
471474
return 2
472475
try:
473-
with open(facts_path, encoding="utf-8") as fh:
474-
facts = json.load(fh)
476+
facts = _read_json(positional[0])
475477
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)
477479
return 2
478-
479480
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+
)
481484
except CollectError as exc:
482485
print(f"own-fix: {exc}", file=sys.stderr)
483486
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+
484492

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
485511
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)
488519
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)
490521
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
493578

494579

495580
_FORMATS = {"human", "github", "msbuild", "sarif", "json"}

0 commit comments

Comments
 (0)