sourcecode 4.15.0__py3-none-any.whl → 4.17.0__py3-none-any.whl

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.
sourcecode/__init__.py CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "4.15.0"
7
+ __version__ = "4.17.0"
@@ -12,9 +12,10 @@ import hmac
12
12
  import json
13
13
  from datetime import datetime, timezone
14
14
  from pathlib import Path
15
- from typing import Any, Optional
15
+ from typing import Any, Callable, Optional
16
16
 
17
17
  from sourcecode import __version__
18
+ from sourcecode.partial_contract import floor_counts, read_count
18
19
 
19
20
  AUDIT_REPORT_SCHEMA = "audit-report-v1"
20
21
 
@@ -40,14 +41,30 @@ def build_audit_report(
40
41
  sign_key: Optional[bytes] = None,
41
42
  risk_limit: int = 10,
42
43
  risk_payload: Optional[dict[str, Any]] = None,
44
+ progress: "Optional[Callable[..., Any]]" = None,
45
+ checkpoint: "Optional[Callable[[str, dict], None]]" = None,
46
+ partial_status: "Optional[Callable[..., dict]]" = None,
43
47
  ) -> dict[str, Any]:
44
- """Build the buyer-readable audit bundle from existing command payloads."""
48
+ """Build the buyer-readable audit bundle from existing command payloads.
49
+
50
+ C3-89. This command composes the whole of `risk`, and for four releases it did
51
+ so with none of the three arguments above — no observer carrying the deadline,
52
+ no checkpoint, no `partial_status` — while `cli` built no `PhasedRun` for it
53
+ either. The operator's budget therefore did not exist here at all: the field
54
+ measured **2 265 s under `ASK_MAX_ANALYSIS_SECONDS=300`** (7,55×), CPU-bound in
55
+ one worker, with no `partial` and no answer. C3-87 bound the second consumer of
56
+ that fact and moved the composition of the `_partial` keys into
57
+ `PhasedRun.status` so the third would get it by construction; this is the third.
58
+ """
45
59
  from sourcecode.posture import build_posture
46
60
 
47
61
  root = Path(root).resolve()
48
62
  if risk_payload is None:
49
63
  from sourcecode.risk import build_risk
50
- risk = build_risk(root, limit=risk_limit, min_band="low", profiles=profiles)
64
+ risk = build_risk(
65
+ root, limit=risk_limit, min_band="low", profiles=profiles,
66
+ progress=progress, checkpoint=checkpoint, partial_status=partial_status,
67
+ )
51
68
  risk_source = "computed"
52
69
  else:
53
70
  risk = risk_payload
@@ -65,8 +82,10 @@ def build_audit_report(
65
82
  "summary": {
66
83
  "risk_model": risk.get("model"),
67
84
  "risk_source": risk_source,
68
- "total_defects": risk.get("total_defects"),
69
- "total_findings": risk.get("total_findings"),
85
+ # F-AV: read under either spelling, and republished under the one
86
+ # the bundle's own `summary.partial` already qualifies.
87
+ "total_defects": read_count(risk, "total_defects"),
88
+ "total_findings": read_count(risk, "total_findings"),
70
89
  "risk_bands": risk.get("by_band", {}),
71
90
  "top_risks_shown": len(top_risks),
72
91
  "endpoint_access": access.get("summary", {}),
@@ -85,6 +104,28 @@ def build_audit_report(
85
104
  "non_coverage": (risk.get("non_coverage") or {}).get("items", []),
86
105
  },
87
106
  }
107
+ # C3-89. A bundle whose evidence was cut says so **before** it is signed, and
108
+ # says it where a reader of the summary sees it — a signature over a truncated
109
+ # payload that does not declare the truncation is the worst artifact this
110
+ # product can produce. The keys come from `risk` itself, whichever way it
111
+ # arrived: a `--from-risk` file that was cut carries its own `_partial`, and
112
+ # packaging it silently would launder a floor into a verdict (C2-31's rule).
113
+ if risk.get("partial"):
114
+ unsigned["summary"]["partial"] = True
115
+ floor_counts(unsigned["summary"], True) # F-AV, on the bundle too
116
+ unsigned["summary"]["counts_are_floor"] = True
117
+ unsigned["summary"]["counts_basis"] = (
118
+ "The risk evidence in this bundle was stopped by the analysis budget. "
119
+ "`total_defects` and `risk_bands` count what the families that ran "
120
+ "measured, and are a floor over the repository."
121
+ )
122
+ _risk_partial = risk.get("_partial")
123
+ if isinstance(_risk_partial, dict):
124
+ unsigned["_partial"] = dict(_risk_partial)
125
+ unsigned["_partial"]["command"] = "audit-report"
126
+ unsigned["_partial"]["cut_in"] = "risk"
127
+ if risk_source == "from-risk":
128
+ unsigned["_partial"]["cut_in"] = "the `--from-risk` payload"
88
129
  from sourcecode.provenance import build_evidence_manifest
89
130
  unsigned["evidence_manifest"] = build_evidence_manifest(
90
131
  unsigned,
@@ -117,7 +158,8 @@ def render_markdown(report: dict[str, Any]) -> str:
117
158
  f"- Repository: `{repo}`",
118
159
  f"- ASK version: `{(report.get('tool') or {}).get('version', '')}`",
119
160
  f"- Profile set: `{profile}`",
120
- f"- Total defects: `{summary.get('total_defects')}`",
161
+ f"- Total defects: `{read_count(summary, 'total_defects')}`"
162
+ + (" **(floor — this run was cut short)**" if summary.get("partial") else ""),
121
163
  f"- Risk bands: `{summary.get('risk_bands')}`",
122
164
  f"- Endpoint access: `{summary.get('endpoint_access')}`",
123
165
  "",
sourcecode/cli.py CHANGED
@@ -24,6 +24,8 @@ from sourcecode.output_encoding import json_ensure_ascii as _json_ensure_ascii
24
24
  from sourcecode.output_encoding import set_ascii_fallback
25
25
  from sourcecode.phased_run import WHY_BUDGET as _WHY_BUDGET
26
26
  from sourcecode.phased_run import PhasedRun
27
+ from sourcecode.partial_contract import PARTIAL_EXIT_CODE as _PARTIAL_EXIT
28
+ from sourcecode.partial_contract import read_count as _read_count
27
29
  from sourcecode import perf
28
30
  from sourcecode.caller_metrics import (
29
31
  CALLER_METRIC_RECONCILIATION,
@@ -238,6 +240,16 @@ def _writes_help_block() -> str:
238
240
  lines.append(f" {name.ljust(width)} {what}")
239
241
  lines.append("")
240
242
  lines.append(" --no-write (ASK_READONLY=1) refuses all of it and says so on stderr.")
243
+ # F-AX: the other half of an audit that may not write — where the repository's
244
+ # own declaration is allowed to live.
245
+ lines.append(
246
+ " --config <path> (ASK_CONFIG) reads the repository's declaration from "
247
+ "outside it,"
248
+ )
249
+ lines.append(
250
+ " so labels and custom security annotations can be "
251
+ "declared without writing."
252
+ )
241
253
  return "\n".join(lines)
242
254
 
243
255
 
@@ -746,7 +758,7 @@ def _preprocess_args(args: list[str]) -> list[str]:
746
758
 
747
759
  def _preprocess_argv() -> None:
748
760
  """Apply _preprocess_args to sys.argv in-place (used by main_entry)."""
749
- modified = _preprocess_args(_apply_no_write(sys.argv[1:]))
761
+ modified = _preprocess_args(_apply_external_config(_apply_no_write(sys.argv[1:])))
750
762
  sys.argv = sys.argv[:1] + modified
751
763
 
752
764
 
@@ -2045,6 +2057,57 @@ NO_WRITE_OPTION_HELP = (
2045
2057
  NO_WRITE_FLAG = "--no-write"
2046
2058
 
2047
2059
 
2060
+ #: The one help string for `--config` (F-AX).
2061
+ CONFIG_OPTION_HELP = (
2062
+ "Read this repository's declaration (data labels, custom security "
2063
+ "annotations) from <path> instead of <repo>/sourcecode.config.json — so an "
2064
+ "auditor can declare without writing into somebody else's tree. Env: "
2065
+ "ASK_CONFIG."
2066
+ )
2067
+
2068
+ #: Handled before the parser, for `--no-write`'s reason: a declaration honoured
2069
+ #: by some commands and not others is worse than none, because the reader cannot
2070
+ #: tell which answer used it.
2071
+ CONFIG_FLAG = "--config"
2072
+
2073
+
2074
+ def _apply_external_config(argv: "list[str]") -> "list[str]":
2075
+ """Consume `--config <path>`, putting the declaration in force. Never raises.
2076
+
2077
+ F-AX. Under `ASK_READONLY=1` the only way to declare anything used to be a
2078
+ file inside the analysed tree, which left `data-exposure`, the `Verified`
2079
+ upgrade for a custom gate, and everything downstream of them unreachable by
2080
+ construction for the exact user this mode exists for — the auditor of
2081
+ somebody else's repository.
2082
+ """
2083
+ if CONFIG_FLAG not in argv:
2084
+ return argv
2085
+ out: "list[str]" = []
2086
+ index = 0
2087
+ while index < len(argv):
2088
+ token = argv[index]
2089
+ if token != CONFIG_FLAG:
2090
+ out.append(token)
2091
+ index += 1
2092
+ continue
2093
+ value = argv[index + 1] if index + 1 < len(argv) else ""
2094
+ if not value or value.startswith("-"):
2095
+ print(
2096
+ f"error: {CONFIG_FLAG} needs a path to a declaration file.\n"
2097
+ f" use: ask {CONFIG_FLAG} ./declarations.json <command> …",
2098
+ file=sys.stderr,
2099
+ )
2100
+ raise SystemExit(2)
2101
+ try:
2102
+ from sourcecode import declarations
2103
+
2104
+ declarations.set_config_path(value)
2105
+ except Exception:
2106
+ pass
2107
+ index += 2
2108
+ return out
2109
+
2110
+
2048
2111
  def _apply_no_write(argv: "list[str]") -> "list[str]":
2049
2112
  """Consume `--no-write` from *argv*, putting the mode in force. Never raises."""
2050
2113
  if NO_WRITE_FLAG not in argv:
@@ -8230,6 +8293,14 @@ def spring_audit_cmd(
8230
8293
  cir = ContextGraph.build(
8231
8294
  file_list, target, progress=_work_sink(_prog)
8232
8295
  ).cir
8296
+ # C3-85: the stretch between the last `linking` tick and the first rule
8297
+ # tick reports nothing, and what stays on the line is `n/n` — a counter
8298
+ # that finished, reading as a stage still running. Measured on keycloak
8299
+ # (5 486 files, warm): **8,2 s of a 24 s run, 34 %**, all of it here.
8300
+ # It has no population to count — it is one walk that builds the model
8301
+ # every rule then reads — so it is named, which is what C3-56 does for
8302
+ # a stage whose denominator would have to be invented.
8303
+ _prog.update(f"building the semantic model over {len(file_list)} Java files")
8233
8304
  _model = SpringSemanticModel.build(cir)
8234
8305
  _run.checkpoint("ir")
8235
8306
  # The IR is built; the rule passes are the work now, and they are not
@@ -8353,37 +8424,15 @@ def spring_audit_cmd(
8353
8424
 
8354
8425
  data = combined.to_compact_dict() if compact else combined.to_dict()
8355
8426
  if _stopped_early:
8356
- data["_partial"] = _run.status(_stopped_early)
8357
- if _rules_partial:
8358
- # C3-79: neither run nor not-run. How far it got is what a
8359
- # reader needs to decide whether to re-run with more time, and
8360
- # the findings it produced before the cut are in the payload.
8361
- data["_partial"]["rules_partially_run"] = _rules_partial
8362
- data["_partial"]["how_to_read"] += (
8363
- " One rule family was stopped part-way through its own "
8364
- "population: "
8365
- + "; ".join(
8366
- f"{'/'.join(entry['rule_ids'])} after "
8367
- f"{entry['units_done']} of {entry['units_total']} "
8368
- f"{entry['unit']}"
8369
- for entry in _rules_partial
8370
- )
8371
- + ". What it found before the cut is reported and is a floor."
8372
- )
8373
- if _rules_cut:
8374
- # C3-71: a phase stopped *inside* is neither completed nor
8375
- # pending, and calling it either would misreport the run. The
8376
- # families it did not reach are named instead.
8377
- data["_partial"]["rules_not_run"] = _rules_cut
8378
- # A phase that was stopped inside is in neither list, and
8379
- # putting it in `phases_pending` alone would claim none of it
8380
- # ran when most of it did.
8381
- data["_partial"]["phases_stopped_inside"] = _phases_cut
8382
- data["_partial"]["how_to_read"] += (
8383
- " The rule pass was stopped between families: "
8384
- f"{', '.join(_rules_cut)} did not run, so every count here "
8385
- "is a floor over the families that did."
8386
- )
8427
+ # C3-87: the block is composed by `PhasedRun.status`, which is the
8428
+ # authority `risk` is now bound to as well — one vocabulary for
8429
+ # "the budget cut this run", not one per command.
8430
+ data["_partial"] = _run.status(
8431
+ _stopped_early,
8432
+ rules_partially_run=_rules_partial,
8433
+ rules_not_run=_rules_cut,
8434
+ phases_stopped_inside=_phases_cut,
8435
+ )
8387
8436
 
8388
8437
  # Non-fatal RIS side-effect — persist summary only (not full findings).
8389
8438
  try:
@@ -8409,8 +8458,23 @@ def spring_audit_cmd(
8409
8458
  else:
8410
8459
  output = _serialize_dict(data, format)
8411
8460
 
8412
- _total = combined.summary.get("total_findings", 0)
8461
+ # F-AV: our own readers ask for the count under either spelling — the break
8462
+ # is meant for a consumer that never asked whether the run finished.
8463
+ _total = _read_count(combined.summary, "total_findings") or 0
8413
8464
  _partial_msg = " — PARTIAL, budget exhausted" if _stopped_early else ""
8465
+ # CL-19: the payload carries `stack_fit`, and the person who most needs it is
8466
+ # the one who will read the counts and stop. Said once, on stderr, so piped
8467
+ # output stays byte-identical.
8468
+ if not combined.spring_detected:
8469
+ _notice(
8470
+ "[ask] stack fit: no Spring detected in this scope. The access, "
8471
+ "transaction and "
8472
+ "Boot-readiness axes model Spring semantics and are NOT measuring "
8473
+ "this repository — read their answers as `unknown`, never as `none`. "
8474
+ "The structural axes (call graph, endpoint census, coupling, JDK "
8475
+ "inventory) answer here as they do anywhere. See `stack_fit` in the "
8476
+ "payload."
8477
+ )
8414
8478
  _emit_command_output(
8415
8479
  output, output_path, copy,
8416
8480
  success_msg=(
@@ -8425,8 +8489,10 @@ def spring_audit_cmd(
8425
8489
 
8426
8490
  if ci and _stopped_early:
8427
8491
  # A gate on incomplete evidence must not read as a pass — the same rule
8428
- # `pr-impact` holds for UNKNOWN.
8429
- raise typer.Exit(code=1)
8492
+ # `pr-impact` holds for UNKNOWN. F-AV: and it must not read as a *failure*
8493
+ # either, because "the gate found something" and "the gate did not finish"
8494
+ # call for different pipeline decisions. 75 is neither 0, 1 nor 2.
8495
+ raise typer.Exit(code=_PARTIAL_EXIT)
8430
8496
  if ci and combined.findings:
8431
8497
  raise typer.Exit(code=1)
8432
8498
 
@@ -8949,10 +9015,18 @@ def risk_cmd(
8949
9015
  profiles=_profile_set(profile),
8950
9016
  # C3-73: 54 minutes with one line on stderr in the field. The
8951
9017
  # factory hands the composer an observer per counted stage.
9018
+ #
9019
+ # C3-87: and the observer carries the deadline. This factory built
9020
+ # its observers with no `stop_when` for four releases, so `risk`
9021
+ # declared `budget_seconds` on the run above and then never asked
9022
+ # whether it was spent — the budget was honoured only between the
9023
+ # two phases. `PhasedRun.exhausted` stays the one authority; what
9024
+ # is new is that the second consumer asks it.
8952
9025
  progress=lambda stage, unit="rule families": _rule_pass_progress(
8953
- _prog, stage, unit
9026
+ _prog, stage, unit, stop_when=_run.exhausted
8954
9027
  ),
8955
9028
  checkpoint=_run.checkpoint,
9029
+ partial_status=lambda **cut: _run.status(_WHY_BUDGET, **cut),
8956
9030
  )
8957
9031
  finally:
8958
9032
  _prog.stop()
@@ -8968,7 +9042,10 @@ def risk_cmd(
8968
9042
  copy,
8969
9043
  success_msg=(
8970
9044
  f"risk written to {output_path} ({data['shown']} of "
8971
- f"{data['total_defects']} defects composed)"
9045
+ f"{_read_count(data, 'total_defects')} defects composed)"
9046
+ # C3-87: the terminal says it too. A reader who never opens the payload
9047
+ # must not take a truncated ranking for the repository's risk.
9048
+ + (" — PARTIAL, budget exhausted" if data.get("partial") else "")
8972
9049
  ),
8973
9050
  )
8974
9051
  # The complete answer is on disk; the checkpoint that stood in for it is not
@@ -9259,26 +9336,58 @@ def audit_report_cmd(
9259
9336
  )
9260
9337
  raise typer.Exit(code=1)
9261
9338
 
9262
- _prog = Progress()
9263
- _prog.start("packaging audit evidence")
9264
- try:
9265
- data = build_audit_report(
9266
- path, profiles=_profile_set(profile), sign_key=key_bytes,
9267
- risk_payload=risk_payload,
9268
- )
9269
- finally:
9270
- _prog.stop()
9339
+ # C3-89: this command composes the whole of `risk`, and it did so with no
9340
+ # deadline of any kind — no `PhasedRun`, no `stop_when` on the observers, no
9341
+ # checkpoint. The field measured 2 265 s under a 300 s budget, CPU-bound in one
9342
+ # worker, killed by hand with nothing to show. `risk` was bound in 4.16.0
9343
+ # (C3-87) and the composition of the `_partial` keys moved into
9344
+ # `PhasedRun.status` precisely so the next consumer would inherit it; this is
9345
+ # that consumer. The phases are `risk`'s own (it is the analysis being run)
9346
+ # plus this command's packaging step.
9347
+ _phase = "packaging audit evidence"
9348
+ _budget = _analysis_budget("audit-report")
9349
+ _run = PhasedRun(
9350
+ command="audit-report",
9351
+ root=path,
9352
+ phases=["audit", "compose", "bundle"],
9353
+ output_path=output_path,
9354
+ budget_seconds=_budget.get("configured_max_seconds"),
9355
+ budget_source=_budget.get("configured_source"),
9356
+ writer=_safe_write_file,
9357
+ )
9358
+ with _expensive_analysis_scope("audit-report", path, _phase):
9359
+ _prog = Progress()
9360
+ _prog.start(_phase)
9361
+ try:
9362
+ data = build_audit_report(
9363
+ path, profiles=_profile_set(profile), sign_key=key_bytes,
9364
+ risk_payload=risk_payload,
9365
+ progress=lambda stage, unit="rule families": _rule_pass_progress(
9366
+ _prog, stage, unit, stop_when=_run.exhausted
9367
+ ),
9368
+ checkpoint=_run.checkpoint,
9369
+ partial_status=lambda **cut: _run.status(_WHY_BUDGET, **cut),
9370
+ )
9371
+ finally:
9372
+ _prog.stop()
9271
9373
  output = render_markdown(data) if fmt == "markdown" else _serialize_dict(data, fmt)
9374
+ _summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
9272
9375
  _emit_command_output(
9273
9376
  output,
9274
9377
  output_path,
9275
9378
  copy,
9276
- success_msg=f"audit report written to {output_path}",
9379
+ success_msg=f"audit report written to {output_path}"
9380
+ # The terminal says it too, for the same reason `risk` does: a signed
9381
+ # bundle that was cut must not be read as the repository's audit.
9382
+ + (" — PARTIAL, budget exhausted" if _summary.get("partial") else ""),
9277
9383
  # A signed artifact cannot be mutated after signing. The report carries
9278
9384
  # tool/version metadata itself, and `signature.payload_sha256` is over
9279
9385
  # the exact JSON payload the user receives.
9280
9386
  stamp_envelope=False,
9281
9387
  )
9388
+ # The complete bundle is written; the checkpoint that stood in for it must not
9389
+ # outlive it (C3-77's invariant, as `risk` applies it).
9390
+ _run.discard_checkpoint()
9282
9391
 
9283
9392
 
9284
9393
  @app.command("migrate-recipe")
@@ -11785,7 +11894,15 @@ def schema_cmd(
11785
11894
  # through the command that already answers "what shape does this release
11786
11895
  # produce?" rather than a command of its own.
11787
11896
  names = available_schemas()
11788
- if name == "facts-v1":
11897
+ # C4-22: one authority for *what this argument accepts*. The registry names
11898
+ # itself (`registry_version`), so the listing, the dispatch below and the
11899
+ # rejection all read the same value — the field's audit was told
11900
+ # `available: ["envelope-v1"]` by a rejection that knew only half of what the
11901
+ # very next line accepts, and concluded that was the whole published surface.
11902
+ registries = [str(load_registry().get("registry_version") or "")]
11903
+ registries = [r for r in registries if r]
11904
+ accepted = list(names) + registries
11905
+ if name in registries:
11789
11906
  _emit_command_output(
11790
11907
  json.dumps(load_registry(), indent=2, ensure_ascii=False),
11791
11908
  output_path,
@@ -11800,7 +11917,7 @@ def schema_cmd(
11800
11917
  # answers have a single authority. Both are contracts this release
11801
11918
  # carries, listed apart because they are not the same kind of thing.
11802
11919
  json.dumps(
11803
- {"schemas": names, "registries": ["facts-v1"]},
11920
+ {"schemas": names, "registries": registries},
11804
11921
  indent=2, ensure_ascii=False,
11805
11922
  ),
11806
11923
  output_path,
@@ -11813,10 +11930,14 @@ def schema_cmd(
11813
11930
  except FileNotFoundError:
11814
11931
  _emit_error_json(
11815
11932
  INVALID_INPUT_CODE,
11816
- f"No published schema named '{name}'.",
11817
- available=names,
11818
- hint="Run `ask schema` to list published schemas.",
11819
- expected="A published schema name.",
11933
+ f"'{name}' is not a published schema or registry.",
11934
+ available=accepted,
11935
+ hint=(
11936
+ "Run `ask schema` to list them: "
11937
+ + ", ".join(accepted)
11938
+ + "."
11939
+ ),
11940
+ expected="One of: " + " | ".join(accepted),
11820
11941
  )
11821
11942
  raise typer.Exit(code=1)
11822
11943
  # A schema document describes the envelope; stamping one into it would put
@@ -11874,8 +11995,17 @@ def config_cmd(
11874
11995
  _answer.say(f"Telemetry: {'enabled' if is_enabled() else 'disabled'} (off by default; opt-in)")
11875
11996
  _answer.say("")
11876
11997
 
11877
- _declaration_path = _repo / CONFIG_FILENAME
11998
+ # F-AX: the declaration may sit outside the analysed tree, and *which* file
11999
+ # answered is the first thing this command exists to say.
12000
+ from sourcecode.declarations import config_path as _config_path, is_external
12001
+
12002
+ _declaration_path = _config_path(_repo) or (_repo / CONFIG_FILENAME)
11878
12003
  _answer.say(f"Repository: {_repo}")
12004
+ if is_external():
12005
+ _answer.say(
12006
+ "Declared with --config / ASK_CONFIG — read from outside the "
12007
+ "repository, which is unchanged by this run."
12008
+ )
11879
12009
  if not _declaration_path.is_file():
11880
12010
  _answer.say(f"Declaration: none at {_declaration_path}")
11881
12011
  _answer.say(
@@ -40,6 +40,7 @@ from pathlib import Path
40
40
  from typing import TYPE_CHECKING, Any, Optional
41
41
 
42
42
  from sourcecode.data_labels import DataLabel, LabelDeclaration, load_labels
43
+ from sourcecode.declarations import source_note as _declaration_source
43
44
 
44
45
  if TYPE_CHECKING: # pragma: no cover - typing only
45
46
  from sourcecode.canonical_ir import CanonicalRepositoryIR
@@ -139,7 +140,10 @@ def build_data_exposure(
139
140
 
140
141
  payload: dict = {
141
142
  "schema_version": SCHEMA_VERSION,
142
- "declaration": decl.to_dict(),
143
+ # F-AX: which file answered. A declaration read from outside the analysed
144
+ # tree is the auditor's case, and a payload that does not say so leaves
145
+ # the reader unable to reproduce the answer.
146
+ "declaration": {**decl.to_dict(), "source": _declaration_source()},
143
147
  "profiles": sorted(profiles) if profiles else None,
144
148
  }
145
149
 
sourcecode/data_labels.py CHANGED
@@ -39,7 +39,9 @@ from pathlib import Path
39
39
  from typing import Optional
40
40
 
41
41
  #: The same file the custom security annotations are declared in.
42
- CONFIG_FILENAME = "sourcecode.config.json"
42
+ #: F-AX: the name lives in `declarations`, which also decides *where* it is
43
+ #: looked for. Re-exported so existing importers keep working.
44
+ from sourcecode.declarations import CONFIG_FILENAME # noqa: E402,F401
43
45
 
44
46
  #: The key under which labels are declared.
45
47
  CONFIG_KEY = "dataLabels"
@@ -108,9 +110,14 @@ def load_labels(root: Optional[Path]) -> LabelDeclaration:
108
110
  Never raises. An absent file is not a problem — it is a repository that has
109
111
  declared nothing, and the caller says so in the words of the question.
110
112
  """
111
- if root is None:
113
+ # F-AX: the path is resolved by one authority, so `--config <external>` is
114
+ # honoured identically here and in `security_config` — a declaration that
115
+ # applied to one loader and not the other would be worse than none.
116
+ from sourcecode.declarations import config_path
117
+
118
+ cfg_path = config_path(root)
119
+ if cfg_path is None:
112
120
  return LabelDeclaration()
113
- cfg_path = Path(root) / CONFIG_FILENAME
114
121
  try:
115
122
  if not cfg_path.is_file():
116
123
  return LabelDeclaration(config_file=None)
@@ -0,0 +1,81 @@
1
+ """declarations.py — where this repository's declaration lives. One authority.
2
+
3
+ `sourcecode.config.json` is what a repository declares **about itself**: the data
4
+ labels `data-exposure` refuses to invent, and the custom security annotations
5
+ `spring-audit` and `posture` will not guess. Two loaders read it, and until this
6
+ module each of them resolved the path itself, from its own copy of the filename.
7
+
8
+ **F-AX.** The path also has to be allowed to sit *outside* the analysed tree. The
9
+ use case is not hypothetical — it is the one field evaluation #20 executed: an
10
+ auditor running over somebody else's repository under `ASK_READONLY=1`, who found
11
+ four commands unreachable **by construction**, because the only way to declare
12
+ anything was to write a file into a tree they had been told not to touch:
13
+
14
+ "bajo restricción read-only, cuatro comandos son inalcanzables por
15
+ construcción. Falta un modo --config-from <ruta externa>."
16
+
17
+ So the resolution order is: an explicit `--config <path>` (or `ASK_CONFIG`),
18
+ then `<root>/sourcecode.config.json`. The override is a whole-process decision
19
+ like `--no-write` — a declaration that applied to some commands and not others
20
+ would be worse than none, because the reader could not tell which answer used it.
21
+
22
+ Nothing here reads or parses the file. It answers one question: *which path*.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ #: The name a repository's own declaration has when it lives in the repository.
32
+ CONFIG_FILENAME = "sourcecode.config.json"
33
+
34
+ #: The environment channel, equal in force to the flag (the `ASK_READONLY`
35
+ #: precedent): a CI job declares once, in the environment, for every command.
36
+ ENV_CONFIG = "ASK_CONFIG"
37
+
38
+ _OVERRIDE: "Optional[Path]" = None
39
+
40
+
41
+ def set_config_path(path: "Optional[Path | str]") -> None:
42
+ """Point every loader at *path* for the rest of this process. Never raises."""
43
+ global _OVERRIDE
44
+ _OVERRIDE = Path(str(path)).expanduser() if path else None
45
+
46
+
47
+ def override() -> "Optional[Path]":
48
+ """The declared external path, from the flag or the environment, or None."""
49
+ if _OVERRIDE is not None:
50
+ return _OVERRIDE
51
+ raw = os.environ.get(ENV_CONFIG, "").strip()
52
+ return Path(raw).expanduser() if raw else None
53
+
54
+
55
+ def config_path(root: "Optional[Path]") -> "Optional[Path]":
56
+ """Where this run's declaration is, whether or not it exists.
57
+
58
+ Returns None only when there is no candidate at all — no override and no
59
+ root — so a caller can distinguish *"nothing was declared"* from *"the file
60
+ it would have been in is absent"*, which is the distinction `ask config`
61
+ prints and `data-exposure` refuses on.
62
+ """
63
+ external = override()
64
+ if external is not None:
65
+ return external
66
+ if root is None:
67
+ return None
68
+ return Path(root) / CONFIG_FILENAME
69
+
70
+
71
+ def is_external() -> bool:
72
+ """True when the declaration in force comes from outside the analysed tree."""
73
+ return override() is not None
74
+
75
+
76
+ def source_note() -> str:
77
+ """One clause naming where the declaration came from, for a payload or a line."""
78
+ external = override()
79
+ if external is None:
80
+ return f"<repo>/{CONFIG_FILENAME}"
81
+ return f"{external} (declared with --config / {ENV_CONFIG})"
@@ -794,7 +794,9 @@ def run_migrate_flow_impl(repo_path: str, min_severity: str = "low") -> dict[str
794
794
  headline[key] = report[key]
795
795
  _summary = report.get("summary", {})
796
796
  if isinstance(_summary, dict):
797
- headline["total_findings"] = _summary.get("total_findings")
797
+ from sourcecode.partial_contract import read_count as _read_count
798
+
799
+ headline["total_findings"] = _read_count(_summary, "total_findings")
798
800
  headline["affected_files"] = _summary.get("affected_files")
799
801
  headline["by_severity"] = _summary.get("by_severity")
800
802
  headline["by_target"] = _summary.get("by_target") or _summary.get("by_rule")