sourcecode 5.2.0__py3-none-any.whl → 5.3.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 +1 -1
- sourcecode/cache_model.py +1 -0
- sourcecode/cli.py +257 -5
- sourcecode/consumer_join.py +357 -0
- sourcecode/environment_resolution.py +200 -12
- sourcecode/format_contract.py +1 -0
- sourcecode/non_coverage.py +12 -8
- sourcecode/perf.py +15 -1
- sourcecode/remedies.py +51 -1
- sourcecode/risk.py +92 -2
- sourcecode/spring_findings.py +39 -0
- sourcecode/spring_impact.py +10 -0
- sourcecode/sql_taint.py +362 -0
- sourcecode/timeline.py +459 -0
- sourcecode/verify_rules.py +9 -0
- sourcecode/waivers.py +384 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/METADATA +52 -14
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/RECORD +21 -17
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-5.2.0.dist-info → sourcecode-5.3.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cache_model.py
CHANGED
|
@@ -368,6 +368,7 @@ COMMANDS: tuple[CommandCache, ...] = (
|
|
|
368
368
|
"`no_ris` instead of a snapshot.",
|
|
369
369
|
"0.2 s either way", analysis_class="core", cold_seconds=0.2, warm_seconds=0.2,
|
|
370
370
|
field_seconds=0.4, field_cache_state="mixed", field_measured_version="5.1.0"),
|
|
371
|
+
CommandCache("timeline", (), "none", False, "Each sample is a fresh materialisation of a different commit, so nothing is shared between samples: two commits are two trees, and a cache keyed on a tree state can only ever serve one of them. The per-sample cost is measured and published in the payload.", analysis_class="ir"),
|
|
371
372
|
CommandCache("trend", (), "none", False, "Reads stored baseline artifacts from disk; analyses no source, so no cache layer applies. Same command as `baseline trend`.", analysis_class="none"),
|
|
372
373
|
CommandCache("baseline", ("parse",), "shared", False,
|
|
373
374
|
"`capture`/`diff`/`trend` over architectural metrics. The field figure is `capture`, "
|
sourcecode/cli.py
CHANGED
|
@@ -201,6 +201,9 @@ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
|
|
|
201
201
|
("experimental", "shape may change in a minor — do not gate CI on it", (
|
|
202
202
|
"risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
|
|
203
203
|
"archetype",
|
|
204
|
+
# F-BJ: the series is one release old and its sampling contract (which
|
|
205
|
+
# window a transition is attributed to) is the part most likely to move.
|
|
206
|
+
"timeline",
|
|
204
207
|
)),
|
|
205
208
|
# `retrieve` publishes 15+ intents whose answers the other commands already
|
|
206
209
|
# give better: measured on the battery, `security-surface` merely re-states
|
|
@@ -6298,6 +6301,13 @@ def endpoints_cmd(
|
|
|
6298
6301
|
help="Where the client lives, when it is not the whole repository "
|
|
6299
6302
|
"(implies --client-usage).",
|
|
6300
6303
|
),
|
|
6304
|
+
consumer: Optional[list[Path]] = typer.Option(
|
|
6305
|
+
None, "--consumer",
|
|
6306
|
+
help="A consumer repository to join against (repeatable). Turns the census "
|
|
6307
|
+
"into a surface-reduction plan: of the routes a rule leaves open, which "
|
|
6308
|
+
"ones somebody calls (fix now) and which nobody calls (delete rather "
|
|
6309
|
+
"than protect).",
|
|
6310
|
+
),
|
|
6301
6311
|
progress_mode: Optional[str] = _progress_option(),
|
|
6302
6312
|
jobs: Optional[int] = _jobs_option(),
|
|
6303
6313
|
) -> None:
|
|
@@ -6447,6 +6457,17 @@ def endpoints_cmd(
|
|
|
6447
6457
|
target, _selected,
|
|
6448
6458
|
client_dirs=[client_dir] if client_dir is not None else None,
|
|
6449
6459
|
)
|
|
6460
|
+
# F-BH. The consumers of a monolith are usually other repositories, so the
|
|
6461
|
+
# question that decides the work — what does anybody call — cannot be answered
|
|
6462
|
+
# from this tree alone. Given them, the plan inverts: an open route nobody
|
|
6463
|
+
# calls is surface to delete rather than surface to guard. A third declared
|
|
6464
|
+
# population, never merged into the census or the servlet surface.
|
|
6465
|
+
if not servlets and consumer:
|
|
6466
|
+
from sourcecode.consumer_join import build_consumer_join
|
|
6467
|
+
|
|
6468
|
+
data["consumer_join"] = build_consumer_join(
|
|
6469
|
+
target, _selected, consumer, access=_endpoint_access_index(target),
|
|
6470
|
+
)
|
|
6450
6471
|
if compact and not servlets:
|
|
6451
6472
|
from sourcecode.degradation import cap_effect
|
|
6452
6473
|
|
|
@@ -7984,6 +8005,139 @@ def trend_cmd(
|
|
|
7984
8005
|
)
|
|
7985
8006
|
|
|
7986
8007
|
|
|
8008
|
+
def _endpoint_access_index(target: Path) -> "dict[str, str]":
|
|
8009
|
+
"""`METHOD path` → the access verdict `posture` publishes, for the consumer join.
|
|
8010
|
+
|
|
8011
|
+
Read from `posture`, which is the authority for it: the plan's whole value is
|
|
8012
|
+
the crossing of *open* with *uncalled*, and deciding "open" a second way here
|
|
8013
|
+
would be a second answer to a question one command already owns. Failure is an
|
|
8014
|
+
empty index rather than an exception — the join still splits called from
|
|
8015
|
+
uncalled, it just cannot say which half is urgent.
|
|
8016
|
+
"""
|
|
8017
|
+
try:
|
|
8018
|
+
from sourcecode.posture import access_resolution, access_verdict
|
|
8019
|
+
|
|
8020
|
+
by_endpoint, _decides = access_resolution(target)
|
|
8021
|
+
except Exception:
|
|
8022
|
+
return {}
|
|
8023
|
+
index: "dict[str, str]" = {}
|
|
8024
|
+
for identity, decision in by_endpoint.items():
|
|
8025
|
+
parts = str(identity).split(":")
|
|
8026
|
+
if len(parts) < 2:
|
|
8027
|
+
continue
|
|
8028
|
+
index[f"{parts[0].upper()} {parts[1]}"] = access_verdict(decision)
|
|
8029
|
+
return index
|
|
8030
|
+
|
|
8031
|
+
|
|
8032
|
+
# ── timeline: since when, and in which window (F-BJ) ──────────────────────────
|
|
8033
|
+
@app.command("timeline")
|
|
8034
|
+
def timeline_cmd(
|
|
8035
|
+
path: Path = typer.Argument(
|
|
8036
|
+
Path("."), help="Repository to walk (default: current directory).",
|
|
8037
|
+
),
|
|
8038
|
+
since: str = typer.Option(
|
|
8039
|
+
...,
|
|
8040
|
+
"--since",
|
|
8041
|
+
help="The ref the series starts after: a tag, a branch, a sha (e.g. v1.0).",
|
|
8042
|
+
),
|
|
8043
|
+
watch: str = typer.Option(
|
|
8044
|
+
"posture",
|
|
8045
|
+
"--watch",
|
|
8046
|
+
help="Comma-separated metrics to follow: posture, data-exposure, "
|
|
8047
|
+
"endpoints, spring-audit.",
|
|
8048
|
+
),
|
|
8049
|
+
max_commits: int = typer.Option(
|
|
8050
|
+
10,
|
|
8051
|
+
"--max-commits",
|
|
8052
|
+
min=1,
|
|
8053
|
+
help="How many commits to analyse. A larger number narrows the window a "
|
|
8054
|
+
"transition is attributed to; at one commit per sample the window "
|
|
8055
|
+
"closes to a single commit.",
|
|
8056
|
+
),
|
|
8057
|
+
first_parent: bool = typer.Option(
|
|
8058
|
+
True,
|
|
8059
|
+
"--first-parent/--all-parents",
|
|
8060
|
+
help="Walk the trunk only (default). A merge's second parent is another "
|
|
8061
|
+
"branch's history.",
|
|
8062
|
+
),
|
|
8063
|
+
output_path: Optional[Path] = typer.Option(
|
|
8064
|
+
None, "--output", "-o", help="Write the series to a file instead of stdout.",
|
|
8065
|
+
),
|
|
8066
|
+
format: str = _format_option(help="Output format: json (default) or yaml."),
|
|
8067
|
+
copy: bool = _copy_option(),
|
|
8068
|
+
progress_mode: Optional[str] = _progress_option(),
|
|
8069
|
+
) -> None:
|
|
8070
|
+
"""[EXPERIMENTAL] When an exposure appeared, and in which commit window.
|
|
8071
|
+
|
|
8072
|
+
\b
|
|
8073
|
+
EXPERIMENTAL: the sampling contract — which commit window a transition is
|
|
8074
|
+
attributed to — is the part most likely to move in a minor. Do not gate CI
|
|
8075
|
+
on it yet.
|
|
8076
|
+
|
|
8077
|
+
\b
|
|
8078
|
+
`data-exposure` says 2 453 routes reach labelled data and nothing decides
|
|
8079
|
+
access to them. The question that follows — and the one a regulator asks
|
|
8080
|
+
first — is SINCE WHEN. This walks the commit series, runs the analyses that
|
|
8081
|
+
already ship at each sampled tree, and publishes where a number MOVED.
|
|
8082
|
+
|
|
8083
|
+
\b
|
|
8084
|
+
Nothing here is a new analysis: every axis is a projection of a payload
|
|
8085
|
+
another command emits (`metrics_read` names it per metric), measured on a
|
|
8086
|
+
read-only materialisation of each commit — the repository is never written
|
|
8087
|
+
to. Re-run that command at that commit and you get the same number.
|
|
8088
|
+
|
|
8089
|
+
\b
|
|
8090
|
+
A sampled commit that could not be analysed is `null` with a reason, never
|
|
8091
|
+
0. With sampling on, a transition names the commit it was OBSERVED at and
|
|
8092
|
+
publishes the window it could have happened in; it is never attributed to a
|
|
8093
|
+
single commit it was not measured at.
|
|
8094
|
+
|
|
8095
|
+
\b
|
|
8096
|
+
Examples:
|
|
8097
|
+
ask timeline . --since v1.0 --watch posture,data-exposure
|
|
8098
|
+
ask timeline . --since HEAD~50 --watch data-exposure --max-commits 50
|
|
8099
|
+
ask timeline . --since v2.0 --watch spring-audit -o series.json
|
|
8100
|
+
"""
|
|
8101
|
+
from sourcecode.timeline import METRICS, TimelineError, build_timeline
|
|
8102
|
+
|
|
8103
|
+
target = _admit_path(path)
|
|
8104
|
+
watched = [name.strip() for name in watch.split(",") if name.strip()]
|
|
8105
|
+
if not watched:
|
|
8106
|
+
_emit_error_json(
|
|
8107
|
+
INVALID_INPUT_CODE,
|
|
8108
|
+
"--watch names at least one metric.",
|
|
8109
|
+
hint=f"--watch {','.join(sorted(METRICS))}",
|
|
8110
|
+
)
|
|
8111
|
+
raise typer.Exit(code=1)
|
|
8112
|
+
|
|
8113
|
+
# Each sample is a full analysis of one tree, so this is the command in the
|
|
8114
|
+
# catalogue whose wait is longest and least bounded by the repository alone.
|
|
8115
|
+
# The spinner names the commit being sampled, because "which of the ten" is
|
|
8116
|
+
# the only progress fact a caller can act on.
|
|
8117
|
+
_prog = Progress()
|
|
8118
|
+
_prog.start(f"walking {len(watched)} metric(s) since {since}")
|
|
8119
|
+
try:
|
|
8120
|
+
data = build_timeline(
|
|
8121
|
+
target, since, watched,
|
|
8122
|
+
max_samples=max_commits,
|
|
8123
|
+
first_parent=first_parent,
|
|
8124
|
+
progress=lambda line: _prog.update(line),
|
|
8125
|
+
)
|
|
8126
|
+
except TimelineError as exc:
|
|
8127
|
+
_prog.stop()
|
|
8128
|
+
_emit_error_json(exc.code, exc.message, **exc.context)
|
|
8129
|
+
raise typer.Exit(code=1)
|
|
8130
|
+
finally:
|
|
8131
|
+
_prog.stop()
|
|
8132
|
+
|
|
8133
|
+
_emit_command_output(
|
|
8134
|
+
_serialize_dict(data, format),
|
|
8135
|
+
output_path,
|
|
8136
|
+
copy,
|
|
8137
|
+
success_msg=f"timeline written to {output_path}",
|
|
8138
|
+
)
|
|
8139
|
+
|
|
8140
|
+
|
|
7987
8141
|
# ── Spring Semantic Audit ─────────────────────────────────────────────────────
|
|
7988
8142
|
|
|
7989
8143
|
|
|
@@ -8478,6 +8632,14 @@ def spring_audit_cmd(
|
|
|
8478
8632
|
`summary.total_defects` is the number of things to fix. Measured:
|
|
8479
8633
|
openmrs-core 38 findings / 29 defects, BroadleafCommerce 19 / 14.
|
|
8480
8634
|
|
|
8635
|
+
\b
|
|
8636
|
+
Waivers: a repository can adjudicate a finding as not-a-defect-here in
|
|
8637
|
+
`.ask/waivers.yml`, keyed on the stable `finding_id`/`defect_id` with a
|
|
8638
|
+
required reason (optional `by`, `expires`). Waived rows leave `findings` and
|
|
8639
|
+
are published in full under `waived`, with `summary.waived_findings` and a
|
|
8640
|
+
`waivers` block naming what was declared, applied, expired and matched
|
|
8641
|
+
nothing. A rule family cannot be waived. See docs/contracts.md.
|
|
8642
|
+
|
|
8481
8643
|
\b
|
|
8482
8644
|
CI/CD usage — this gate is ABSOLUTE (any finding fails, including debt that
|
|
8483
8645
|
was already there). For a baseline-relative gate that fails only on findings
|
|
@@ -8677,6 +8839,27 @@ def spring_audit_cmd(
|
|
|
8677
8839
|
|
|
8678
8840
|
combined = SpringAuditResult.merge(results, scope=scope)
|
|
8679
8841
|
|
|
8842
|
+
# F-BI: adjudication, applied over the whole population and never
|
|
8843
|
+
# inside one scope. A malformed waivers file stops the command
|
|
8844
|
+
# rather than reading as "nothing was waived" — a broken file must
|
|
8845
|
+
# not decide a gate in either direction.
|
|
8846
|
+
from sourcecode.waivers import WaiversError as _WaiversError
|
|
8847
|
+
from sourcecode.waivers import apply_to_result as _apply_waivers
|
|
8848
|
+
|
|
8849
|
+
try:
|
|
8850
|
+
combined = _apply_waivers(combined, target)
|
|
8851
|
+
except _WaiversError as _exc:
|
|
8852
|
+
_prog.stop()
|
|
8853
|
+
_emit_error_json(
|
|
8854
|
+
INVALID_INPUT_CODE,
|
|
8855
|
+
f"The waivers this repository declares could not be read: {_exc}",
|
|
8856
|
+
hint=(
|
|
8857
|
+
"Each entry needs one of `finding_id`/`defect_id` and a "
|
|
8858
|
+
"`reason`. See docs/contracts.md."
|
|
8859
|
+
),
|
|
8860
|
+
)
|
|
8861
|
+
raise typer.Exit(code=1)
|
|
8862
|
+
|
|
8680
8863
|
# C2-31: a phase that never *started* is a cut this envelope cannot
|
|
8681
8864
|
# learn from its own scopes — no auditor ran to record it. The run
|
|
8682
8865
|
# knows, so it says so here, and `finalize()` applies the same ceiling
|
|
@@ -9005,6 +9188,13 @@ def verify_cmd(
|
|
|
9005
9188
|
Contracts live in `.ask/contracts.yml` (see `ask verify-edit --help` for the
|
|
9006
9189
|
rule kinds). No contracts declared → nothing was verified, exit 2.
|
|
9007
9190
|
|
|
9191
|
+
\b
|
|
9192
|
+
Waivers: `.ask/waivers.yml` adjudicates a finding as not-a-defect-here, keyed
|
|
9193
|
+
on its stable finding/defect id with a required reason. A waived finding does
|
|
9194
|
+
not hold this gate red; `ask spring-audit` publishes what was waived, by whom
|
|
9195
|
+
and why. A baseline records accepted DEBT — a waiver says it was never a
|
|
9196
|
+
defect. See docs/contracts.md.
|
|
9197
|
+
|
|
9008
9198
|
\b
|
|
9009
9199
|
Exit codes (with --ci, the default): 0 = pass, 1 = violations blocked,
|
|
9010
9200
|
2 = unverified (no contracts declared, contracts unreadable, or the
|
|
@@ -9973,6 +10163,16 @@ def posture_cmd(
|
|
|
9973
10163
|
"spring.profiles.active from an explicit deployment env/dotenv file."
|
|
9974
10164
|
),
|
|
9975
10165
|
),
|
|
10166
|
+
profile_from: Optional[list[Path]] = typer.Option(
|
|
10167
|
+
None,
|
|
10168
|
+
"--profile-from",
|
|
10169
|
+
help=(
|
|
10170
|
+
"Take the active profile set from the artefact that decides it — a "
|
|
10171
|
+
"Kubernetes manifest, compose file, WebLogic deployment plan, "
|
|
10172
|
+
"Dockerfile, dotenv or launch script (repeatable). If it decides "
|
|
10173
|
+
"nothing, the repository's own verdict stands."
|
|
10174
|
+
),
|
|
10175
|
+
),
|
|
9976
10176
|
property_overrides: Optional[list[str]] = typer.Option(
|
|
9977
10177
|
None,
|
|
9978
10178
|
"--property",
|
|
@@ -10107,6 +10307,44 @@ def posture_cmd(
|
|
|
10107
10307
|
)
|
|
10108
10308
|
raise typer.Exit(code=1)
|
|
10109
10309
|
|
|
10310
|
+
# F-BG. `--profile-from` names the artefact that decides the profile set, which
|
|
10311
|
+
# is the hop the flagship used to end at. It cannot be combined with a flag
|
|
10312
|
+
# that *states* the set: --profile and --diff both name one, and reading a
|
|
10313
|
+
# manifest to then ignore it would be a payload that describes two answers and
|
|
10314
|
+
# gives one.
|
|
10315
|
+
profile_from_block: "Optional[dict]" = None
|
|
10316
|
+
decided_profiles: "Optional[set[str]]" = None
|
|
10317
|
+
artefact_signals: list = []
|
|
10318
|
+
if profile_from:
|
|
10319
|
+
if profile or diff:
|
|
10320
|
+
_emit_error_json(
|
|
10321
|
+
INVALID_INPUT_CODE,
|
|
10322
|
+
"--profile-from reads the profile set from a deployment artefact; "
|
|
10323
|
+
"--profile and --diff state one. Pass one or the other.",
|
|
10324
|
+
hint="ask posture . --profile-from k8s/deployment-prod.yaml",
|
|
10325
|
+
)
|
|
10326
|
+
raise typer.Exit(code=1)
|
|
10327
|
+
from sourcecode.environment_resolution import (
|
|
10328
|
+
collect_deployment_artefact_signals,
|
|
10329
|
+
decide_profiles_from_artefacts,
|
|
10330
|
+
)
|
|
10331
|
+
|
|
10332
|
+
for artefact in profile_from:
|
|
10333
|
+
artefact_path = Path(artefact).expanduser()
|
|
10334
|
+
if not artefact_path.is_file():
|
|
10335
|
+
_emit_error_json(
|
|
10336
|
+
INVALID_INPUT_CODE,
|
|
10337
|
+
f"--profile-from must name a readable file (got {str(artefact)!r}).",
|
|
10338
|
+
hint="ask posture . --profile-from k8s/deployment-prod.yaml",
|
|
10339
|
+
)
|
|
10340
|
+
raise typer.Exit(code=1)
|
|
10341
|
+
artefact_signals.extend(
|
|
10342
|
+
collect_deployment_artefact_signals(artefact_path, root=path)
|
|
10343
|
+
)
|
|
10344
|
+
profile_from_block = decide_profiles_from_artefacts(profile_from, root=path)
|
|
10345
|
+
if profile_from_block.get("decided"):
|
|
10346
|
+
decided_profiles = set(profile_from_block["profiles"] or [])
|
|
10347
|
+
|
|
10110
10348
|
# One spinner over the whole dispatch: every branch below resolves the
|
|
10111
10349
|
# conditional bean graph, and `--diff-ref` resolves it twice (once per ref).
|
|
10112
10350
|
# try/finally, because several branches leave through typer.Exit and a
|
|
@@ -10124,7 +10362,7 @@ def posture_cmd(
|
|
|
10124
10362
|
"`--env-file prod.env` as deployment evidence.",
|
|
10125
10363
|
)
|
|
10126
10364
|
raise typer.Exit(code=1)
|
|
10127
|
-
deployment_signals =
|
|
10365
|
+
deployment_signals = list(artefact_signals)
|
|
10128
10366
|
if env_file is not None:
|
|
10129
10367
|
from sourcecode.environment_resolution import collect_env_file_signals
|
|
10130
10368
|
|
|
@@ -10136,7 +10374,8 @@ def posture_cmd(
|
|
|
10136
10374
|
hint="ask posture . --resolve-environments --env-file prod.env",
|
|
10137
10375
|
)
|
|
10138
10376
|
raise typer.Exit(code=1)
|
|
10139
|
-
deployment_signals
|
|
10377
|
+
deployment_signals.extend(collect_env_file_signals(env_path, root=path))
|
|
10378
|
+
deployment_signals = deployment_signals or None
|
|
10140
10379
|
data = resolve_environments(
|
|
10141
10380
|
path, overrides, deployment_signals=deployment_signals,
|
|
10142
10381
|
)
|
|
@@ -10165,7 +10404,8 @@ def posture_cmd(
|
|
|
10165
10404
|
try:
|
|
10166
10405
|
data = diff_posture_refs(
|
|
10167
10406
|
path,
|
|
10168
|
-
|
|
10407
|
+
decided_profiles if decided_profiles is not None
|
|
10408
|
+
else (_parse_set(profile) if profile else set()),
|
|
10169
10409
|
base_ref.strip(),
|
|
10170
10410
|
head_ref.strip() if head_ref is not None else None,
|
|
10171
10411
|
overrides,
|
|
@@ -10194,10 +10434,22 @@ def posture_cmd(
|
|
|
10194
10434
|
left_raw, right_raw = diff.split(":", 1)
|
|
10195
10435
|
data = diff_posture(path, _parse_set(left_raw), _parse_set(right_raw), overrides)
|
|
10196
10436
|
else:
|
|
10197
|
-
data = build_posture(
|
|
10437
|
+
data = build_posture(
|
|
10438
|
+
path,
|
|
10439
|
+
decided_profiles if decided_profiles is not None
|
|
10440
|
+
else (_parse_set(profile) if profile else set()),
|
|
10441
|
+
overrides,
|
|
10442
|
+
)
|
|
10198
10443
|
finally:
|
|
10199
10444
|
_prog.stop()
|
|
10200
10445
|
|
|
10446
|
+
# F-BG. The block travels with every shape the command can answer in, and it
|
|
10447
|
+
# says whether it changed the answer: a `decided: false` block beside an
|
|
10448
|
+
# unchanged verdict is the honest outcome of pointing at a file that does not
|
|
10449
|
+
# decide, not a silent no-op.
|
|
10450
|
+
if profile_from_block is not None and isinstance(data, dict):
|
|
10451
|
+
data["profile_from"] = profile_from_block
|
|
10452
|
+
|
|
10201
10453
|
_emit_command_output(
|
|
10202
10454
|
_serialize_dict(data, format),
|
|
10203
10455
|
output_path,
|
|
@@ -14178,7 +14430,7 @@ HELP_PANELS: "tuple[tuple[str, tuple[str, ...]], ...]" = (
|
|
|
14178
14430
|
)),
|
|
14179
14431
|
("Experimental — shape may change", (
|
|
14180
14432
|
"risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
|
|
14181
|
-
"archetype", "retrieve",
|
|
14433
|
+
"archetype", "timeline", "retrieve",
|
|
14182
14434
|
)),
|
|
14183
14435
|
)
|
|
14184
14436
|
|