Skip to content

Commit d99bedd

Browse files
PhysShellclaude
andcommitted
feat(S0-B): own-fix subscriptions candidates — the analysis-only candidate collector
Part B of S0 (Part A shipped the extractor `fix` metadata). A new `python -m ownlang own-fix subscriptions candidates <facts.json> --config <own.toml> --class <FQN> [--finding-id <ID>]... --output <candidates.json> [--root <dir>]` turns the `--fix-candidates` facts into a deterministic candidates.json. It changes no source — the C# `fix` block already carries the semantics; this only assembles, identifies, filters and orders. Per the locked contract: - `--class` is an EXACT fully-qualified name; a partial / nested / generated / duplicate-FQN / unknown type is a hard error. - `finding_id` is versioned and LINE-INDEPENDENT: SHA256(version . containing_type . enclosing_member . event_identity . source_identity . handler_identity . occurrence_ordinal) — span/line are location metadata, never in the id. - `target_api` is PINNED from config (`[weak-subscription].target`, or the sole `subscribe` entry) — several entries with no target is a hard error, never a silent first-pick. - `allowed_actions` = `[convert_acquire, manual_review]` only for a proven INotifyPropertyChanged contract, else `[manual_review]`. `convert_exact_teardown` is NOT offered in S0 (deferred to S2 with a pinned remove API); teardown metadata is carried but grants no conversion. - a released subscription is not a leak, so not a candidate; candidates are deterministically ordered (file, span start, id); every source file gets a SHA-256 (S2 refuses if the file changed); the output carries the selection-request safety envelope (allowed_types, selected_findings, constraints). `--finding-id` filters and hard-fails on any unknown id (the chicken-and-egg fix: no ids = discover all eligible; ids = explicit final selection). `ownlang/fix_candidates.py` (core), `ownlang/config.py::load_target_subscribe`, `own-fix` dispatch in `__main__.py`, `tests/test_fix_candidates.py` (18 checks: line-independence, INPC permission tiering, released-skip, per-file SHA, ordering, partial/nested/generated/dup/unknown/unknown-id rejections, target pinning), an end-to-end CI step over the real extractor facts, and a `spec/CLI.md` row. Local: ruff + mypy (ownlang) + run_tests (18/0) green; end-to-end own-fix over the extractor facts verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G
1 parent 52ec06b commit d99bedd

6 files changed

Lines changed: 604 additions & 4 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,6 +1632,50 @@ jobs:
16321632
fi
16331633
echo "OK: fix-candidate metadata correct; flag-off is byte-identical to the pre-S0 golden"
16341634
1635+
# S0 Part B: the `own-fix subscriptions candidates` collector turns the fix
1636+
# metadata into a deterministic candidates.json (analysis-only). Reuses fc_on.json.
1637+
- name: S0 fix-candidates — own-fix collector (Part B)
1638+
run: |
1639+
on="$RUNNER_TEMP/fc_on.json"
1640+
printf '[weak-subscription]\nsubscribe = ["WeakEvents.AddPropertyChanged"]\n' > "$RUNNER_TEMP/fix.toml"
1641+
# A leaky INotifyPropertyChanged subscription -> one candidate, convert_acquire allowed.
1642+
python -m ownlang own-fix subscriptions candidates "$on" \
1643+
--config "$RUNNER_TEMP/fix.toml" \
1644+
--class Own.Samples.FixCandidates.InpcNoTeardown \
1645+
--output "$RUNNER_TEMP/cand.json" --root .
1646+
python -c "
1647+
import json, sys
1648+
d = json.load(open(sys.argv[1]))
1649+
assert d['target_api'] == {'subscribe': 'WeakEvents.AddPropertyChanged'}, d['target_api']
1650+
assert len(d['candidates']) == 1, d['candidates']
1651+
c = d['candidates'][0]
1652+
assert c['allowed_actions'] == ['convert_acquire', 'manual_review'], c['allowed_actions']
1653+
assert c['event_contract'] == 'inotify_property_changed'
1654+
assert c['finding_id'].startswith('OWN001:sha256:'), c['finding_id']
1655+
assert d['source_files'][0]['sha256'].startswith('sha256:')
1656+
assert d['selection']['constraints']['max_types_changed'] == 1
1657+
print('OK: candidate bundle well-formed')
1658+
" "$RUNNER_TEMP/cand.json"
1659+
# A name_only event -> manual_review only.
1660+
python -m ownlang own-fix subscriptions candidates "$on" \
1661+
--config "$RUNNER_TEMP/fix.toml" \
1662+
--class Own.Samples.FixCandidates.NameOnlySubscriber \
1663+
--output "$RUNNER_TEMP/cand2.json" --root .
1664+
python -c "import json,sys; a=json.load(open(sys.argv[1]))['candidates'][0]['allowed_actions']; sys.exit(0 if a==['manual_review'] else 1)" "$RUNNER_TEMP/cand2.json" \
1665+
|| { echo "FAIL: name_only should be manual_review only"; exit 1; }
1666+
# A nested class -> hard error.
1667+
if python -m ownlang own-fix subscriptions candidates "$on" --config "$RUNNER_TEMP/fix.toml" \
1668+
--class Own.Samples.FixCandidates.OuterWithNested.Nested --output "$RUNNER_TEMP/x.json" --root . 2>/dev/null; then
1669+
echo "FAIL: a nested class must be refused"; exit 1
1670+
fi
1671+
# An unknown finding-id -> hard error.
1672+
if python -m ownlang own-fix subscriptions candidates "$on" --config "$RUNNER_TEMP/fix.toml" \
1673+
--class Own.Samples.FixCandidates.InpcNoTeardown --finding-id OWN001:sha256:deadbeef \
1674+
--output "$RUNNER_TEMP/x.json" --root . 2>/dev/null; then
1675+
echo "FAIL: an unknown finding-id must be refused"; exit 1
1676+
fi
1677+
echo "OK: own-fix collector — bundle, permission tiering, and hard rejections"
1678+
16351679
# The OwnTS frontend spike (P-020 Own.React): the SAME OwnIR seam, fed from a
16361680
# React .tsx instead of C#. Two analyses over the one core: (1) a useEffect
16371681
# acquire (timer / subscribe / listener) with no cleanup return is the core's

‎ownlang/__main__.py‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,17 +403,107 @@ def cmd_ownir(path: str, fmt: str = "human", severity: str = "error",
403403
return 1 if leaks else 0
404404

405405

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] = []
427+
i = 0
428+
while i < len(args):
429+
a = args[i]
430+
if a.startswith("--"):
431+
if i + 1 >= len(args):
432+
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)
446+
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
452+
else:
453+
print(f"own-fix: unexpected argument {a!r}", file=sys.stderr)
454+
return 2
455+
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)
462+
return 2
463+
464+
from ownlang.config import ConfigError, load_target_subscribe
465+
from ownlang.fix_candidates import CollectError, collect_candidates
466+
467+
try:
468+
target = load_target_subscribe(config_path)
469+
except ConfigError as exc:
470+
print(f"own-fix: {exc}", file=sys.stderr)
471+
return 2
472+
try:
473+
with open(facts_path, encoding="utf-8") as fh:
474+
facts = json.load(fh)
475+
except (OSError, ValueError) as exc:
476+
print(f"own-fix: cannot read facts {facts_path}: {exc}", file=sys.stderr)
477+
return 2
478+
479+
try:
480+
envelope = collect_candidates(facts, target, class_fqn, finding_ids or None, root)
481+
except CollectError as exc:
482+
print(f"own-fix: {exc}", file=sys.stderr)
483+
return 2
484+
485+
with open(output, "w", encoding="utf-8") as fh:
486+
fh.write(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
487+
print(f"own-fix: wrote {len(envelope['candidates'])} candidate(s) -> {output}")
488+
return 0
489+
490+
406491
_FORMATS = {"human", "github", "msbuild", "sarif", "json"}
407492
_SEVERITIES = {"error", "warning"}
408493
_VERBOSITY = {"quiet", "normal", "verbose"}
409494

410495

411496
def main(argv: list[str]) -> int:
412497
if not argv or argv[0] not in {"check", "emit", "cfg", "report", "ownir",
413-
"summaries", "explain", "config"}:
498+
"summaries", "explain", "config", "own-fix"}:
414499
print(__doc__)
415500
return 2
416501
cmd = argv[0]
502+
# `own-fix subscriptions candidates` (S0 Part B): analysis-only candidate export
503+
# for the subscription-autofix pipeline. Has its own nested shape, so it is handled
504+
# before the single-positional path below.
505+
if cmd == "own-fix":
506+
return cmd_own_fix(argv[1:])
417507
# `config` is the minimal P-015 carrier (P-035): read an explicit own.toml and
418508
# print the declared weak-subscribe "SimpleType.Method" names, one per line, for
419509
# own-check.sh to forward to the extractor. A malformed config is a HARD error

‎ownlang/config.py‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,15 @@ def _weak_subscribe_from(data: dict[str, object], path: str) -> list[str]:
4747
if not isinstance(table, dict):
4848
raise ConfigError(f"{path}: [weak-subscription] must be a table")
4949

50-
# Only `subscribe` is honoured in this slice. Reject any other key so a typo
50+
# Only `subscribe` (P-035 recognition) and `target` (the S0 fix target, read by
51+
# `load_target_subscribe`) are honoured. Reject any other key so a typo
5152
# (`subscribes`, `unsubscribe` before it is designed, ...) is a hard error, not
5253
# a silently-ignored no-op that hides a caller mistake.
53-
unknown = sorted(set(table) - {"subscribe"})
54+
unknown = sorted(set(table) - {"subscribe", "target"})
5455
if unknown:
5556
raise ConfigError(
5657
f"{path}: [weak-subscription] has unsupported key(s): "
57-
f"{', '.join(unknown)} (only `subscribe` is supported in this slice)"
58+
f"{', '.join(unknown)} (only `subscribe` and `target` are supported)"
5859
)
5960

6061
entries = table.get("subscribe", [])
@@ -67,6 +68,43 @@ def _weak_subscribe_from(data: dict[str, object], path: str) -> list[str]:
6768
return list(entries)
6869

6970

71+
def load_target_subscribe(path: str) -> str:
72+
"""Return the ONE weak-subscribe wrapper the fixer should emit (S0 `target_api`).
73+
74+
Pinned explicitly, never guessed: either ``[weak-subscription].target`` (a single
75+
``"SimpleType.Method"``), or — as a convenience when there is no ambiguity — the sole
76+
``subscribe`` entry when the list has exactly one. Zero or several ``subscribe`` entries
77+
with no explicit ``target`` is a :class:`ConfigError`: silently taking the first would
78+
bake an unintended API into a public candidates contract.
79+
"""
80+
try:
81+
with open(path, "rb") as fh:
82+
data = tomllib.load(fh)
83+
except FileNotFoundError as exc:
84+
raise ConfigError(f"config file not found: {path}") from exc
85+
except tomllib.TOMLDecodeError as exc:
86+
raise ConfigError(f"{path}: invalid TOML: {exc}") from exc
87+
88+
table = data.get("weak-subscription")
89+
if not isinstance(table, dict):
90+
raise ConfigError(
91+
f"{path}: a [weak-subscription] table is required to pin a fix target"
92+
)
93+
target = table.get("target")
94+
if target is not None:
95+
if not isinstance(target, str):
96+
raise ConfigError(f"{path}: [weak-subscription].target must be a string")
97+
_validate_entry(target, path)
98+
return target
99+
subscribe = _weak_subscribe_from(data, path)
100+
if len(subscribe) == 1:
101+
return subscribe[0]
102+
raise ConfigError(
103+
f"{path}: cannot pin a fix target: set [weak-subscription].target, or declare "
104+
f"exactly one [weak-subscription].subscribe entry (found {len(subscribe)})"
105+
)
106+
107+
70108
def _validate_entry(entry: str, path: str) -> None:
71109
"""A declared entry must be exactly ``"SimpleType.Method"``.
72110

0 commit comments

Comments
 (0)