@salesforce/afv-skills 1.67.0 → 1.68.0
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.
- package/README.md +55 -5
- package/package.json +1 -1
- package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
- package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
- package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
- package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
- package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
- package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
- package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
- package/skills/dx-lifecycle-center/SKILL.md +5 -2
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
- package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
- package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
- package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
- package/skills/dx-lifecycle-center/references/deploy.md +2 -2
- package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
- package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
- package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
- package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
- package/skills/dx-org-devhub-configure/SKILL.md +85 -84
- package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
- package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
- package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
- package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
- package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
- package/skills/dx-scratch-def-configure/SKILL.md +163 -0
- package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
- package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
- package/skills/dx-scratch-def-configure/references/features.json +3270 -0
- package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
- package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
- package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
- package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
- package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
- package/skills/education-cloud-domain-configure/SKILL.md +1 -1
- package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
- package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
- package/skills/experience-design-validate/SKILL.md +1 -1
- package/skills/experience-search-coordinate/SKILL.md +18 -12
- package/skills/experience-search-coordinate/references/content-route.md +79 -1
- package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
- package/skills/platform-apex-generate/SKILL.md +14 -11
- package/skills/platform-apex-test-generate/SKILL.md +2 -1
- package/skills/platform-datamask-run/SKILL.md +52 -71
- package/skills/platform-datamask-run/references/api-surface.md +10 -4
- package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
- package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
- package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
- package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
- package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
- package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
- package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
- package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
- package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
- package/skills/platform-policy-rule-generate/SKILL.md +84 -232
- package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
- package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
- package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
- package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
- package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
- package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
- package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
- package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
- package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
- package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
- package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
- package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
- package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
- package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
- package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
- package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
- package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
- package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
- package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
- package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
- package/skills/platform-policy-rule-generate/references/templates.md +2 -2
- package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
- package/skills/platform-report-generate/SKILL.md +9 -4
- package/skills/platform-report-generate/references/chart-types.md +26 -2
- package/skills/platform-report-generate/references/column-names.md +43 -0
- package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
- package/skills/platform-report-generate/references/verification-checklist.md +10 -2
- package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
- package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
- package/skills/service-digital-engagement-channel-configure/references/channel_settings.md +110 -14
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Parse PUBLIC scratch-org config-values into features.json + settings-map.json.
|
|
3
|
+
|
|
4
|
+
Runtime (customer) path: lib_scratch_def.load_runtime_catalog fetches, parses
|
|
5
|
+
into in-memory maps, and never writes the skill directory. This module's
|
|
6
|
+
--seed / --out / --merge CLIs are test-only for fixture files in temp dirs.
|
|
7
|
+
|
|
8
|
+
--seed reads an offline markdown feed. --fetch downloads the PUBLIC docs
|
|
9
|
+
markdown page, converts it to the enricher feed, then merges into existing
|
|
10
|
+
JSON in --out. A thin live parse refuses to write. Broken inline JSON
|
|
11
|
+
examples are skipped as one count warning (not a hard fail).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import html
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
import urllib.request
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from datetime import date
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
sys.dont_write_bytecode = True
|
|
28
|
+
|
|
29
|
+
SOURCE_MD_URL = (
|
|
30
|
+
"https://developer.salesforce.com/docs/platform/sfdx-dev/guide/"
|
|
31
|
+
"sfdx-dev-scratch-orgs-def-file-config-values.md"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Title-case product phrases in PUBLIC blurbs ("Revenue Cloud", "Experience Cloud").
|
|
35
|
+
_TITLE_PHRASE_RE = re.compile(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b")
|
|
36
|
+
_FEATURE_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
|
37
|
+
_SETTING_PATH_RE = re.compile(
|
|
38
|
+
r"^[A-Za-z][A-Za-z0-9]*\.[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
BULLET_FEATURE_RE = re.compile(
|
|
42
|
+
r"^\*\s+\*\*(?P<name>[A-Za-z][A-Za-z0-9_]*)(?::\s*(?:<value>|<value>| <value>))?\*\*"
|
|
43
|
+
)
|
|
44
|
+
QUANTITY_RANGE_RE = re.compile(
|
|
45
|
+
r"(?:Indicate a value (?:from|between)|Supported Quantities)\s*"
|
|
46
|
+
r"(?P<a>[\d,]+)\s*[–\-]\s*(?P<b>[\d,]+)",
|
|
47
|
+
re.IGNORECASE,
|
|
48
|
+
)
|
|
49
|
+
MULTIPLIER_RE = re.compile(r"[Mm]ultiplier(?:\s+is)?[:\s]+(?P<m>\d+)")
|
|
50
|
+
EDITION_RE = re.compile(
|
|
51
|
+
r"(?:Available|Not available) in[^.]*Edition[^.]*\.",
|
|
52
|
+
re.IGNORECASE,
|
|
53
|
+
)
|
|
54
|
+
# "must also include communitiesSettings > enableNetworksEnabled"
|
|
55
|
+
REQUIRED_SETTING_RE = re.compile(
|
|
56
|
+
r"must also include\s+([A-Za-z][A-Za-z0-9_]*)\s*>\s*([A-Za-z][A-Za-z0-9_]*)",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
)
|
|
59
|
+
# "must also include the CustomerDataPlatform feature"
|
|
60
|
+
REQUIRED_FEATURE_RE = re.compile(
|
|
61
|
+
r"must also include the\s+([A-Za-z][A-Za-z0-9_]*)\s+feature",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
)
|
|
64
|
+
# PUBLIC markdown uses ```nolang (and rarely ```json) for scratch-def examples.
|
|
65
|
+
JSON_FENCE_RE = re.compile(
|
|
66
|
+
r"```(?:json|nolang)\s*\n(.*?)```",
|
|
67
|
+
re.DOTALL | re.IGNORECASE,
|
|
68
|
+
)
|
|
69
|
+
# Live docs put each feature under ## Name (### in HTML-converted feeds).
|
|
70
|
+
_FEATURE_HEADING_RE = re.compile(
|
|
71
|
+
r"^#{2,3}\s+"
|
|
72
|
+
r"(?P<name>[A-Za-z][A-Za-z0-9_]*)"
|
|
73
|
+
r"(?P<q>"
|
|
74
|
+
r"\\:VALUE"
|
|
75
|
+
r"|:VALUE"
|
|
76
|
+
r"|:\s*VALUE"
|
|
77
|
+
r"|:\s*(?:<value>|<value>)"
|
|
78
|
+
r")?"
|
|
79
|
+
r"\s*$"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Public docs markdown: - **[Name](#anchor)** / - **[Name\:VALUE](#anchor)**
|
|
83
|
+
_DOCS_FEATURE_BULLET_RE = re.compile(
|
|
84
|
+
r"^-\s+\*\*\["
|
|
85
|
+
r"(?P<name>[A-Za-z][A-Za-z0-9_]*)"
|
|
86
|
+
r"(?:\s*\([^)]*\))?"
|
|
87
|
+
r"(?P<q>\\:VALUE|:VALUE|: VALUE)?"
|
|
88
|
+
r"\]\([^)]*\)\*\*\s*$",
|
|
89
|
+
re.MULTILINE,
|
|
90
|
+
)
|
|
91
|
+
THIN_ABS_COUNT = 250
|
|
92
|
+
THIN_RATIO = 0.9
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _clean_name(raw: str) -> tuple[str, bool]:
|
|
96
|
+
"""Return (canonical_name, quantified). Strip :<value> suffix."""
|
|
97
|
+
name = html.unescape(raw.strip())
|
|
98
|
+
quantified = False
|
|
99
|
+
if ":" in name:
|
|
100
|
+
base, _, _rest = name.partition(":")
|
|
101
|
+
quantified = True
|
|
102
|
+
name = base.strip()
|
|
103
|
+
return name, quantified
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def camel_words(name: str) -> str:
|
|
107
|
+
"""CoreCpq → 'core cpq'; B2BCommerce → 'b2b commerce'."""
|
|
108
|
+
spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", name)
|
|
109
|
+
spaced = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", " ", spaced)
|
|
110
|
+
return " ".join(spaced.lower().split())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def title_phrases(description: str) -> list[str]:
|
|
114
|
+
"""Product phrases copied from PUBLIC prose, not a hand token list."""
|
|
115
|
+
return [m.group(0).lower() for m in _TITLE_PHRASE_RE.finditer(description or "")]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def derived_synonyms(name: str, description: str) -> list[str]:
|
|
119
|
+
"""Catalog-derived lookup phrases. Grows/shrinks with whatever PUBLIC lists."""
|
|
120
|
+
syn: list[str] = []
|
|
121
|
+
seen = {name.lower()}
|
|
122
|
+
|
|
123
|
+
def add(raw: str) -> None:
|
|
124
|
+
phrase = " ".join(raw.lower().split())
|
|
125
|
+
if len(phrase) < 4 or phrase in seen:
|
|
126
|
+
return
|
|
127
|
+
seen.add(phrase)
|
|
128
|
+
syn.append(phrase)
|
|
129
|
+
|
|
130
|
+
add(camel_words(name))
|
|
131
|
+
add(name.replace("_", " "))
|
|
132
|
+
for phrase in title_phrases(description):
|
|
133
|
+
add(phrase)
|
|
134
|
+
return syn
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _phrases_for_entry(name: str, entry: dict[str, Any]) -> list[str]:
|
|
138
|
+
phrases = [name, camel_words(name)]
|
|
139
|
+
phrases.extend(entry.get("synonyms") or [])
|
|
140
|
+
phrases.extend(title_phrases(str(entry.get("description") or "")))
|
|
141
|
+
out: list[str] = []
|
|
142
|
+
seen: set[str] = set()
|
|
143
|
+
for raw in phrases:
|
|
144
|
+
phrase = " ".join(str(raw).lower().split())
|
|
145
|
+
if len(phrase) < 4 or phrase in seen:
|
|
146
|
+
continue
|
|
147
|
+
seen.add(phrase)
|
|
148
|
+
out.append(phrase)
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def longest_phrase_match(raw: str, features_doc: dict) -> str | None:
|
|
153
|
+
"""Map a user phrase onto today's catalog (longest contained phrase wins)."""
|
|
154
|
+
hay = " ".join((raw or "").lower().split())
|
|
155
|
+
if len(hay) < 4:
|
|
156
|
+
return None
|
|
157
|
+
best_name: str | None = None
|
|
158
|
+
best_len = 0
|
|
159
|
+
for name, entry in (features_doc.get("features") or {}).items():
|
|
160
|
+
if not isinstance(entry, dict):
|
|
161
|
+
continue
|
|
162
|
+
for phrase in _phrases_for_entry(name, entry):
|
|
163
|
+
if phrase == hay or phrase in hay:
|
|
164
|
+
if len(phrase) > best_len:
|
|
165
|
+
best_len = len(phrase)
|
|
166
|
+
best_name = name
|
|
167
|
+
return best_name
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _parse_int(s: str) -> int:
|
|
171
|
+
return int(s.replace(",", ""))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _first_paragraph(block: str) -> str:
|
|
175
|
+
lines = []
|
|
176
|
+
for line in block.splitlines():
|
|
177
|
+
s = line.strip()
|
|
178
|
+
if not s:
|
|
179
|
+
if lines:
|
|
180
|
+
break
|
|
181
|
+
continue
|
|
182
|
+
if (
|
|
183
|
+
s.startswith("###")
|
|
184
|
+
or s.startswith("```")
|
|
185
|
+
or s.startswith("* ")
|
|
186
|
+
or s.startswith("- ")
|
|
187
|
+
):
|
|
188
|
+
if lines:
|
|
189
|
+
break
|
|
190
|
+
continue
|
|
191
|
+
# strip leading list markers from more-info bullets
|
|
192
|
+
if s.startswith("* "):
|
|
193
|
+
s = s[2:].strip()
|
|
194
|
+
lines.append(s)
|
|
195
|
+
if len(" ".join(lines)) > 400:
|
|
196
|
+
break
|
|
197
|
+
text = " ".join(lines)
|
|
198
|
+
# keep a few sentences
|
|
199
|
+
parts = re.split(r"(?<=[.!?])\s+", text)
|
|
200
|
+
return " ".join(parts[:3]).strip()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _extract_quantity(block: str) -> dict[str, int] | None:
|
|
204
|
+
m = QUANTITY_RANGE_RE.search(block)
|
|
205
|
+
if not m:
|
|
206
|
+
return None
|
|
207
|
+
q = {"min": _parse_int(m.group("a")), "max": _parse_int(m.group("b")), "multiplier": 1}
|
|
208
|
+
mm = MULTIPLIER_RE.search(block)
|
|
209
|
+
if mm:
|
|
210
|
+
q["multiplier"] = int(mm.group("m"))
|
|
211
|
+
return q
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _extract_edition_notes(block: str) -> str | None:
|
|
215
|
+
matches = EDITION_RE.findall(block)
|
|
216
|
+
if not matches:
|
|
217
|
+
return None
|
|
218
|
+
return " ".join(m.strip() for m in matches)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def lower_camel_settings_key(key: str) -> str:
|
|
222
|
+
"""Scratch-def settings use lower camelCase; Metadata API uses UpperCamel.
|
|
223
|
+
|
|
224
|
+
PUBLIC docs sometimes paste Metadata API names (IndustriesSettings). Force
|
|
225
|
+
the category's first letter lower so emit matches create-time expectations.
|
|
226
|
+
"""
|
|
227
|
+
if not key or not isinstance(key, str):
|
|
228
|
+
return key
|
|
229
|
+
if key[0].isupper():
|
|
230
|
+
return key[0].lower() + key[1:]
|
|
231
|
+
return key
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def normalize_settings_path(path: str) -> str:
|
|
235
|
+
"""Lower-camel the settings *category* (first path segment) only."""
|
|
236
|
+
if not path or "." not in path:
|
|
237
|
+
return lower_camel_settings_key(path)
|
|
238
|
+
cat, rest = path.split(".", 1)
|
|
239
|
+
return f"{lower_camel_settings_key(cat)}.{rest}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _extract_required_from_prose(block: str) -> list[dict[str, Any]]:
|
|
243
|
+
out = []
|
|
244
|
+
for m in REQUIRED_SETTING_RE.finditer(block):
|
|
245
|
+
category, field = m.group(1), m.group(2)
|
|
246
|
+
path = normalize_settings_path(f"{category}.{field}")
|
|
247
|
+
out.append(
|
|
248
|
+
{
|
|
249
|
+
"path": path,
|
|
250
|
+
"type": "boolean",
|
|
251
|
+
"example": True,
|
|
252
|
+
"source": "public-docs prose",
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _extract_depends_on_features(block: str) -> list[str]:
|
|
259
|
+
return [m.group(1) for m in REQUIRED_FEATURE_RE.finditer(block)]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_markdown(text: str) -> tuple[dict[str, dict], dict[str, dict], list[str]]:
|
|
263
|
+
"""Return (features, by_feature_settings, warnings)."""
|
|
264
|
+
warnings: list[str] = []
|
|
265
|
+
features: dict[str, dict] = {}
|
|
266
|
+
by_feature: dict[str, dict] = defaultdict(lambda: {"required": [], "common": []})
|
|
267
|
+
|
|
268
|
+
# --- ## / ### feature sections (richer than the TOC bullets) ---
|
|
269
|
+
lines = text.splitlines()
|
|
270
|
+
i = 0
|
|
271
|
+
while i < len(lines):
|
|
272
|
+
hm = _FEATURE_HEADING_RE.match(lines[i])
|
|
273
|
+
if not hm or hm.group("name") in _CHROME_HEADINGS:
|
|
274
|
+
i += 1
|
|
275
|
+
continue
|
|
276
|
+
name = hm.group("name")
|
|
277
|
+
quantified = bool(hm.group("q"))
|
|
278
|
+
|
|
279
|
+
# Keep ### More Information / Supported Quantities inside this feature.
|
|
280
|
+
j = i + 1
|
|
281
|
+
while j < len(lines) and not _FEATURE_HEADING_RE.match(lines[j]):
|
|
282
|
+
j += 1
|
|
283
|
+
block = "\n".join(lines[i + 1 : j])
|
|
284
|
+
desc = _first_paragraph(block) or name
|
|
285
|
+
quantity = _extract_quantity(block) if quantified else None
|
|
286
|
+
edition = _extract_edition_notes(block)
|
|
287
|
+
entry = {
|
|
288
|
+
"name": name,
|
|
289
|
+
"description": desc,
|
|
290
|
+
"quantified": quantified,
|
|
291
|
+
"editionNotes": edition,
|
|
292
|
+
"synonyms": [],
|
|
293
|
+
"settingsCoverage": "unknown",
|
|
294
|
+
"exampleCount": 0,
|
|
295
|
+
}
|
|
296
|
+
deps = _extract_depends_on_features(block)
|
|
297
|
+
if deps:
|
|
298
|
+
entry["dependsOnFeatures"] = deps
|
|
299
|
+
if quantity:
|
|
300
|
+
entry["quantity"] = quantity
|
|
301
|
+
# Prefer first/richer entry; merge if bullet already seeded a thinner one
|
|
302
|
+
if name not in features or len(desc) > len(features[name].get("description", "")):
|
|
303
|
+
prev = features.get(name, {})
|
|
304
|
+
if prev.get("synonyms") and not entry["synonyms"]:
|
|
305
|
+
entry["synonyms"] = prev["synonyms"]
|
|
306
|
+
if prev.get("dependsOnFeatures") and not entry.get("dependsOnFeatures"):
|
|
307
|
+
entry["dependsOnFeatures"] = prev["dependsOnFeatures"]
|
|
308
|
+
if prev.get("editionNotes") and not entry.get("editionNotes"):
|
|
309
|
+
entry["editionNotes"] = prev["editionNotes"]
|
|
310
|
+
if prev.get("quantity") and "quantity" not in entry:
|
|
311
|
+
entry["quantity"] = prev["quantity"]
|
|
312
|
+
if prev.get("quantified"):
|
|
313
|
+
entry["quantified"] = True
|
|
314
|
+
features[name] = entry
|
|
315
|
+
else:
|
|
316
|
+
if deps and not features[name].get("dependsOnFeatures"):
|
|
317
|
+
features[name]["dependsOnFeatures"] = deps
|
|
318
|
+
if edition and not features[name].get("editionNotes"):
|
|
319
|
+
features[name]["editionNotes"] = edition
|
|
320
|
+
if quantity and "quantity" not in features[name]:
|
|
321
|
+
features[name]["quantity"] = quantity
|
|
322
|
+
if quantified:
|
|
323
|
+
features[name]["quantified"] = True
|
|
324
|
+
for req in _extract_required_from_prose(block):
|
|
325
|
+
_add_setting(by_feature[name]["required"], req)
|
|
326
|
+
i = j
|
|
327
|
+
|
|
328
|
+
# --- bullet list fallback (fills gaps) ---
|
|
329
|
+
for idx, line in enumerate(lines):
|
|
330
|
+
bm = BULLET_FEATURE_RE.match(line)
|
|
331
|
+
if not bm:
|
|
332
|
+
continue
|
|
333
|
+
raw = bm.group("name")
|
|
334
|
+
quantified = "value" in line.lower()
|
|
335
|
+
name = raw
|
|
336
|
+
# gather following prose until blank or next bullet
|
|
337
|
+
desc_lines = []
|
|
338
|
+
k = idx + 1
|
|
339
|
+
while k < len(lines):
|
|
340
|
+
s = lines[k].strip()
|
|
341
|
+
if not s or s.startswith("* **") or s.startswith("##") or s.startswith("```"):
|
|
342
|
+
break
|
|
343
|
+
desc_lines.append(s)
|
|
344
|
+
k += 1
|
|
345
|
+
desc = " ".join(desc_lines)[:500] or name
|
|
346
|
+
quantity = _extract_quantity(desc) if quantified else None
|
|
347
|
+
edition = _extract_edition_notes(desc)
|
|
348
|
+
if name not in features:
|
|
349
|
+
entry = {
|
|
350
|
+
"name": name,
|
|
351
|
+
"description": desc,
|
|
352
|
+
"quantified": quantified,
|
|
353
|
+
"editionNotes": edition,
|
|
354
|
+
"synonyms": [],
|
|
355
|
+
"settingsCoverage": "unknown",
|
|
356
|
+
"exampleCount": 0,
|
|
357
|
+
}
|
|
358
|
+
if quantity:
|
|
359
|
+
entry["quantity"] = quantity
|
|
360
|
+
features[name] = entry
|
|
361
|
+
else:
|
|
362
|
+
if quantified and not features[name].get("quantified"):
|
|
363
|
+
features[name]["quantified"] = True
|
|
364
|
+
if quantity and "quantity" not in features[name]:
|
|
365
|
+
features[name]["quantity"] = quantity
|
|
366
|
+
if edition and not features[name].get("editionNotes"):
|
|
367
|
+
features[name]["editionNotes"] = edition
|
|
368
|
+
if len(desc) > len(features[name].get("description", "")):
|
|
369
|
+
features[name]["description"] = desc
|
|
370
|
+
for req in _extract_required_from_prose(desc):
|
|
371
|
+
_add_setting(by_feature[name]["required"], req)
|
|
372
|
+
|
|
373
|
+
# --- JSON examples ---
|
|
374
|
+
broken_json = 0
|
|
375
|
+
for fm in JSON_FENCE_RE.finditer(text):
|
|
376
|
+
raw_json = fm.group(1).strip()
|
|
377
|
+
# PUBLIC examples sometimes use curly quotes.
|
|
378
|
+
cleaned = (
|
|
379
|
+
raw_json.replace("\u201c", '"')
|
|
380
|
+
.replace("\u201d", '"')
|
|
381
|
+
.replace("\u2018", "'")
|
|
382
|
+
.replace("\u2019", "'")
|
|
383
|
+
.replace("“", '"')
|
|
384
|
+
.replace("”", '"')
|
|
385
|
+
)
|
|
386
|
+
try:
|
|
387
|
+
obj = json.loads(cleaned)
|
|
388
|
+
except json.JSONDecodeError:
|
|
389
|
+
broken_json += 1
|
|
390
|
+
continue
|
|
391
|
+
if not isinstance(obj, dict):
|
|
392
|
+
continue
|
|
393
|
+
feat_list = obj.get("features") or []
|
|
394
|
+
settings = obj.get("settings") or {}
|
|
395
|
+
if not isinstance(feat_list, list):
|
|
396
|
+
continue
|
|
397
|
+
bases_in_example: list[str] = []
|
|
398
|
+
for f in feat_list:
|
|
399
|
+
if not isinstance(f, str):
|
|
400
|
+
continue
|
|
401
|
+
base = f.split(":", 1)[0]
|
|
402
|
+
bases_in_example.append(base)
|
|
403
|
+
if base in features:
|
|
404
|
+
features[base]["exampleCount"] = features[base].get("exampleCount", 0) + 1
|
|
405
|
+
# Fold settings only onto features that own the category — do not
|
|
406
|
+
# attribute every settings path in a multi-feature example to every
|
|
407
|
+
# feature (that bloated Communities.common with Revenue Cloud paths).
|
|
408
|
+
if isinstance(settings, dict):
|
|
409
|
+
for path, val in _flatten_settings(settings):
|
|
410
|
+
owners = _settings_path_owners(path, bases_in_example, by_feature)
|
|
411
|
+
if not owners and len(bases_in_example) == 1:
|
|
412
|
+
owners = [bases_in_example[0]]
|
|
413
|
+
item = {
|
|
414
|
+
"path": path,
|
|
415
|
+
"type": _json_type(val),
|
|
416
|
+
"example": val,
|
|
417
|
+
"source": "public-docs example",
|
|
418
|
+
}
|
|
419
|
+
for base in owners:
|
|
420
|
+
req_paths = {r["path"] for r in by_feature[base]["required"]}
|
|
421
|
+
if path in req_paths:
|
|
422
|
+
continue
|
|
423
|
+
_add_setting(by_feature[base]["common"], item)
|
|
424
|
+
|
|
425
|
+
if broken_json:
|
|
426
|
+
warnings.append(f"skip broken JSON example: {broken_json}")
|
|
427
|
+
|
|
428
|
+
# mark settingsCoverage
|
|
429
|
+
for name, feat in features.items():
|
|
430
|
+
if by_feature[name]["required"] or by_feature[name]["common"]:
|
|
431
|
+
feat["settingsCoverage"] = "known"
|
|
432
|
+
else:
|
|
433
|
+
feat["settingsCoverage"] = "unknown"
|
|
434
|
+
feat["synonyms"] = derived_synonyms(name, str(feat.get("description") or ""))
|
|
435
|
+
|
|
436
|
+
# drop empty by_feature entries
|
|
437
|
+
by_feature_out = {
|
|
438
|
+
k: v for k, v in by_feature.items() if v["required"] or v["common"]
|
|
439
|
+
}
|
|
440
|
+
return features, by_feature_out, warnings
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _feature_setting_categories(entry: dict) -> set[str]:
|
|
444
|
+
"""Settings categories already attached to a feature (prose required + examples)."""
|
|
445
|
+
cats: set[str] = set()
|
|
446
|
+
for bucket in ("required", "common"):
|
|
447
|
+
for item in entry.get(bucket) or []:
|
|
448
|
+
path = item.get("path") if isinstance(item, dict) else None
|
|
449
|
+
if not path:
|
|
450
|
+
continue
|
|
451
|
+
cats.add(normalize_settings_path(str(path)).split(".", 1)[0])
|
|
452
|
+
return cats
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _settings_path_owners(
|
|
456
|
+
path: str, bases_in_example: list[str], by_feature: dict
|
|
457
|
+
) -> list[str]:
|
|
458
|
+
"""Attribute a settings path using the catalog, not a frozen owner table.
|
|
459
|
+
|
|
460
|
+
Prefer features in this example that already own the category via required
|
|
461
|
+
prose. Else match category stem against feature names present in the example.
|
|
462
|
+
"""
|
|
463
|
+
category = normalize_settings_path(path).split(".", 1)[0]
|
|
464
|
+
owned = [
|
|
465
|
+
b
|
|
466
|
+
for b in bases_in_example
|
|
467
|
+
if category in _feature_setting_categories(by_feature.get(b) or {})
|
|
468
|
+
]
|
|
469
|
+
if owned:
|
|
470
|
+
return owned
|
|
471
|
+
cat_core = re.sub(r"Settings$", "", category, flags=re.IGNORECASE).lower()
|
|
472
|
+
return [b for b in bases_in_example if cat_core and cat_core in b.lower()]
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _add_setting(bucket: list[dict], item: dict) -> None:
|
|
476
|
+
item = dict(item)
|
|
477
|
+
item["path"] = normalize_settings_path(str(item.get("path") or ""))
|
|
478
|
+
if any(x["path"] == item["path"] for x in bucket):
|
|
479
|
+
return
|
|
480
|
+
bucket.append(item)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _flatten_settings(obj: dict, prefix: str = "") -> list[tuple[str, Any]]:
|
|
484
|
+
out: list[tuple[str, Any]] = []
|
|
485
|
+
for k, v in obj.items():
|
|
486
|
+
key = lower_camel_settings_key(k) if not prefix else k
|
|
487
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
488
|
+
if isinstance(v, dict):
|
|
489
|
+
out.extend(_flatten_settings(v, path))
|
|
490
|
+
else:
|
|
491
|
+
out.append((normalize_settings_path(path) if not prefix else path, v))
|
|
492
|
+
return out
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _json_type(v: Any) -> str:
|
|
496
|
+
if isinstance(v, bool):
|
|
497
|
+
return "boolean"
|
|
498
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
499
|
+
return "integer"
|
|
500
|
+
if isinstance(v, float):
|
|
501
|
+
return "number"
|
|
502
|
+
if isinstance(v, list):
|
|
503
|
+
return "array"
|
|
504
|
+
if isinstance(v, dict):
|
|
505
|
+
return "object"
|
|
506
|
+
return "string"
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
_CHROME_HEADINGS = frozenset({"Subject", "Description"})
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def looks_like_docs_markdown(text: str) -> bool:
|
|
513
|
+
return bool(_DOCS_FEATURE_BULLET_RE.search(text))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def docs_markdown_to_feed(text: str) -> str:
|
|
517
|
+
"""Turn PUBLIC docs markdown (- **[Name](#a)**) into the enricher bullet feed."""
|
|
518
|
+
lines = text.splitlines()
|
|
519
|
+
out: list[str] = []
|
|
520
|
+
i = 0
|
|
521
|
+
while i < len(lines):
|
|
522
|
+
m = _DOCS_FEATURE_BULLET_RE.match(lines[i].rstrip())
|
|
523
|
+
if not m:
|
|
524
|
+
out.append(lines[i])
|
|
525
|
+
i += 1
|
|
526
|
+
continue
|
|
527
|
+
name = m.group("name")
|
|
528
|
+
if name in _CHROME_HEADINGS:
|
|
529
|
+
i += 1
|
|
530
|
+
continue
|
|
531
|
+
quantified = bool(m.group("q"))
|
|
532
|
+
bullet = f"* **{name}:<value>**" if quantified else f"* **{name}**"
|
|
533
|
+
out.append(bullet)
|
|
534
|
+
i += 1
|
|
535
|
+
while i < len(lines) and not lines[i].strip():
|
|
536
|
+
i += 1
|
|
537
|
+
desc_parts: list[str] = []
|
|
538
|
+
while i < len(lines):
|
|
539
|
+
raw = lines[i]
|
|
540
|
+
if _DOCS_FEATURE_BULLET_RE.match(raw.rstrip()) or raw.startswith("##"):
|
|
541
|
+
break
|
|
542
|
+
if not raw.strip():
|
|
543
|
+
i += 1
|
|
544
|
+
if i < len(lines) and _DOCS_FEATURE_BULLET_RE.match(lines[i].rstrip()):
|
|
545
|
+
break
|
|
546
|
+
continue
|
|
547
|
+
desc_parts.append(raw.strip())
|
|
548
|
+
i += 1
|
|
549
|
+
if desc_parts:
|
|
550
|
+
out.append(" ".join(desc_parts))
|
|
551
|
+
out.append("")
|
|
552
|
+
return "\n".join(out)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def feed_from_text(text: str) -> str:
|
|
556
|
+
"""Normalize PUBLIC docs markdown into the enricher feed."""
|
|
557
|
+
if looks_like_docs_markdown(text):
|
|
558
|
+
return docs_markdown_to_feed(text)
|
|
559
|
+
return text
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def fetch_url(url: str) -> tuple[str, str]:
|
|
563
|
+
"""GET url; raise RuntimeError on network/HTTP failure (no bare traceback)."""
|
|
564
|
+
req = urllib.request.Request(
|
|
565
|
+
url, headers={"User-Agent": "dx-scratch-def-configure-scraper/1.0"}
|
|
566
|
+
)
|
|
567
|
+
try:
|
|
568
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
569
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
570
|
+
effective = resp.geturl() or url
|
|
571
|
+
except Exception as e: # noqa: BLE001 — surface as RuntimeError for callers
|
|
572
|
+
raise RuntimeError(f"network fetch failed for {url}: {e}") from e
|
|
573
|
+
return raw, effective
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def fetch_live_feed() -> tuple[str, str]:
|
|
577
|
+
"""Fetch PUBLIC docs markdown only. Raises RuntimeError on failure."""
|
|
578
|
+
try:
|
|
579
|
+
raw, url = fetch_url(SOURCE_MD_URL)
|
|
580
|
+
except Exception as e: # noqa: BLE001 — surface as RuntimeError
|
|
581
|
+
raise RuntimeError(f"PUBLIC catalog fetch failed (markdown: {e})") from e
|
|
582
|
+
if not looks_like_docs_markdown(raw):
|
|
583
|
+
raise RuntimeError(
|
|
584
|
+
"PUBLIC catalog fetch failed (markdown URL did not look like the "
|
|
585
|
+
"feature catalog)"
|
|
586
|
+
)
|
|
587
|
+
print(f"Fetched markdown catalog {url}", file=sys.stderr)
|
|
588
|
+
return docs_markdown_to_feed(raw), url
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def live_parse_too_thin(live_count: int, existing_count: int | None) -> bool:
|
|
592
|
+
if live_count < THIN_ABS_COUNT:
|
|
593
|
+
return True
|
|
594
|
+
if existing_count is not None and live_count < existing_count * THIN_RATIO:
|
|
595
|
+
return True
|
|
596
|
+
return False
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _union_settings(a: list | None, b: list | None) -> list[dict]:
|
|
600
|
+
out: list[dict] = []
|
|
601
|
+
for item in list(a or []) + list(b or []):
|
|
602
|
+
if not isinstance(item, dict) or not item.get("path"):
|
|
603
|
+
continue
|
|
604
|
+
_add_setting(out, item)
|
|
605
|
+
return out
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _merge_feature_entry(existing: dict, live: dict) -> dict:
|
|
609
|
+
out = dict(existing)
|
|
610
|
+
live_desc = str(live.get("description") or "")
|
|
611
|
+
exist_desc = str(existing.get("description") or "")
|
|
612
|
+
if len(live_desc) > len(exist_desc):
|
|
613
|
+
out["description"] = live_desc
|
|
614
|
+
if live.get("quantified") or existing.get("quantified"):
|
|
615
|
+
out["quantified"] = True
|
|
616
|
+
if live.get("quantity") and not out.get("quantity"):
|
|
617
|
+
out["quantity"] = live["quantity"]
|
|
618
|
+
live_ed = live.get("editionNotes")
|
|
619
|
+
exist_ed = out.get("editionNotes")
|
|
620
|
+
if live_ed and (not exist_ed or len(str(live_ed)) > len(str(exist_ed))):
|
|
621
|
+
out["editionNotes"] = live_ed
|
|
622
|
+
syn: list[str] = []
|
|
623
|
+
for s in list(existing.get("synonyms") or []) + list(live.get("synonyms") or []):
|
|
624
|
+
if s not in syn:
|
|
625
|
+
syn.append(s)
|
|
626
|
+
out["synonyms"] = syn
|
|
627
|
+
deps: list[str] = []
|
|
628
|
+
for d in list(existing.get("dependsOnFeatures") or []) + list(
|
|
629
|
+
live.get("dependsOnFeatures") or []
|
|
630
|
+
):
|
|
631
|
+
if d not in deps:
|
|
632
|
+
deps.append(d)
|
|
633
|
+
if deps:
|
|
634
|
+
out["dependsOnFeatures"] = deps
|
|
635
|
+
out["exampleCount"] = max(
|
|
636
|
+
int(existing.get("exampleCount") or 0), int(live.get("exampleCount") or 0)
|
|
637
|
+
)
|
|
638
|
+
return out
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def merge_catalogs(
|
|
642
|
+
existing_features: dict[str, dict],
|
|
643
|
+
existing_by: dict[str, dict],
|
|
644
|
+
live_features: dict[str, dict],
|
|
645
|
+
live_by: dict[str, dict],
|
|
646
|
+
) -> tuple[dict[str, dict], dict[str, dict]]:
|
|
647
|
+
merged_f: dict[str, dict] = {}
|
|
648
|
+
for name in set(existing_features) | set(live_features):
|
|
649
|
+
a = existing_features.get(name)
|
|
650
|
+
b = live_features.get(name)
|
|
651
|
+
if a and b:
|
|
652
|
+
merged_f[name] = _merge_feature_entry(a, b)
|
|
653
|
+
else:
|
|
654
|
+
merged_f[name] = dict(a or b or {})
|
|
655
|
+
merged_by: dict[str, dict] = {}
|
|
656
|
+
for name in set(existing_by) | set(live_by):
|
|
657
|
+
ea = existing_by.get(name) or {}
|
|
658
|
+
lb = live_by.get(name) or {}
|
|
659
|
+
entry = {
|
|
660
|
+
"required": _union_settings(ea.get("required"), lb.get("required")),
|
|
661
|
+
"common": _union_settings(ea.get("common"), lb.get("common")),
|
|
662
|
+
}
|
|
663
|
+
req_paths = {x["path"] for x in entry["required"]}
|
|
664
|
+
entry["common"] = [c for c in entry["common"] if c.get("path") not in req_paths]
|
|
665
|
+
if entry["required"] or entry["common"]:
|
|
666
|
+
merged_by[name] = entry
|
|
667
|
+
for name, feat in merged_f.items():
|
|
668
|
+
if merged_by.get(name, {}).get("required") or merged_by.get(name, {}).get("common"):
|
|
669
|
+
feat["settingsCoverage"] = "known"
|
|
670
|
+
elif "settingsCoverage" not in feat:
|
|
671
|
+
feat["settingsCoverage"] = "unknown"
|
|
672
|
+
return merged_f, merged_by
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def load_existing_catalog(out_dir: Path) -> tuple[dict[str, dict], dict[str, dict], int] | None:
|
|
676
|
+
path = out_dir / "features.json"
|
|
677
|
+
if not path.is_file():
|
|
678
|
+
return None
|
|
679
|
+
features_doc = json.loads(path.read_text(encoding="utf-8"))
|
|
680
|
+
settings_path = out_dir / "settings-map.json"
|
|
681
|
+
settings_doc = (
|
|
682
|
+
json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.is_file() else {}
|
|
683
|
+
)
|
|
684
|
+
feats = features_doc.get("features") or {}
|
|
685
|
+
by_feature = settings_doc.get("byFeature") or {}
|
|
686
|
+
count = int(features_doc.get("featureCount") or len(feats))
|
|
687
|
+
return feats, by_feature, count
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def sanity_check(features: dict[str, dict], by_feature: dict[str, dict]) -> str | None:
|
|
691
|
+
"""Shape of the parse only — never pin today's PUBLIC token names."""
|
|
692
|
+
if not features:
|
|
693
|
+
return "parse produced no features"
|
|
694
|
+
bad_names = [n for n in features if not _FEATURE_NAME_RE.match(n)]
|
|
695
|
+
if bad_names:
|
|
696
|
+
return f"illegal feature names: {bad_names[:5]}"
|
|
697
|
+
for _feat, buckets in by_feature.items():
|
|
698
|
+
for bucket in ("required", "common"):
|
|
699
|
+
for item in buckets.get(bucket) or []:
|
|
700
|
+
if not isinstance(item, dict):
|
|
701
|
+
return f"settings item is not an object under {_feat}"
|
|
702
|
+
path = str(item.get("path") or "")
|
|
703
|
+
if path and not _SETTING_PATH_RE.match(path):
|
|
704
|
+
return f"illegal settings path {path!r} under {_feat}"
|
|
705
|
+
return None
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def emit(
|
|
709
|
+
features: dict[str, dict],
|
|
710
|
+
by_feature: dict[str, dict],
|
|
711
|
+
out_dir: Path,
|
|
712
|
+
version: str,
|
|
713
|
+
fetched_at: str | None = None,
|
|
714
|
+
source_url: str | None = None,
|
|
715
|
+
) -> None:
|
|
716
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
717
|
+
stamp = {
|
|
718
|
+
"fetchedAt": fetched_at or date.today().isoformat(),
|
|
719
|
+
"sourceUrl": source_url or SOURCE_MD_URL,
|
|
720
|
+
"version": version,
|
|
721
|
+
}
|
|
722
|
+
features_doc = {
|
|
723
|
+
**stamp,
|
|
724
|
+
"featureCount": len(features),
|
|
725
|
+
"features": dict(sorted(features.items(), key=lambda kv: kv[0].lower())),
|
|
726
|
+
}
|
|
727
|
+
settings_doc = {
|
|
728
|
+
**stamp,
|
|
729
|
+
"byFeature": dict(sorted(by_feature.items(), key=lambda kv: kv[0].lower())),
|
|
730
|
+
}
|
|
731
|
+
features_path = out_dir / "features.json"
|
|
732
|
+
settings_path = out_dir / "settings-map.json"
|
|
733
|
+
features_path.write_text(json.dumps(features_doc, indent=2) + "\n", encoding="utf-8")
|
|
734
|
+
settings_path.write_text(json.dumps(settings_doc, indent=2) + "\n", encoding="utf-8")
|
|
735
|
+
print(f"Wrote {features_path} ({features_doc['featureCount']} features)")
|
|
736
|
+
print(f"Wrote {settings_path} ({len(settings_doc['byFeature'])} feature setting maps)")
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def main(argv: list[str] | None = None) -> int:
|
|
740
|
+
p = argparse.ArgumentParser(description=__doc__)
|
|
741
|
+
p.add_argument(
|
|
742
|
+
"--seed",
|
|
743
|
+
type=Path,
|
|
744
|
+
default=None,
|
|
745
|
+
help="Offline markdown feed of the PUBLIC config-values page (authoring)",
|
|
746
|
+
)
|
|
747
|
+
p.add_argument(
|
|
748
|
+
"--fetch",
|
|
749
|
+
action="store_true",
|
|
750
|
+
help="Fetch the live PUBLIC page, convert to a feed, enrich, merge into --out",
|
|
751
|
+
)
|
|
752
|
+
p.add_argument(
|
|
753
|
+
"--merge",
|
|
754
|
+
action="store_true",
|
|
755
|
+
help="Merge this parse into existing JSON in --out (implied by --fetch)",
|
|
756
|
+
)
|
|
757
|
+
p.add_argument(
|
|
758
|
+
"--out",
|
|
759
|
+
type=Path,
|
|
760
|
+
required=True,
|
|
761
|
+
help="Directory for features.json + settings-map.json",
|
|
762
|
+
)
|
|
763
|
+
p.add_argument(
|
|
764
|
+
"--version",
|
|
765
|
+
default=None,
|
|
766
|
+
help="Stamp version (default: seed-YYYY-MM-DD or scrape-YYYY-MM-DD)",
|
|
767
|
+
)
|
|
768
|
+
args = p.parse_args(argv)
|
|
769
|
+
|
|
770
|
+
today = date.today().isoformat()
|
|
771
|
+
source_url = SOURCE_MD_URL
|
|
772
|
+
do_merge = bool(args.fetch or args.merge)
|
|
773
|
+
if args.fetch:
|
|
774
|
+
print(f"Fetching {SOURCE_MD_URL} …", file=sys.stderr)
|
|
775
|
+
try:
|
|
776
|
+
text, source_url = fetch_live_feed()
|
|
777
|
+
except Exception as e: # noqa: BLE001 — keep shipped cache; exit non-zero
|
|
778
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
779
|
+
print(
|
|
780
|
+
"Keeping the current cache. Retry when the network is available.",
|
|
781
|
+
file=sys.stderr,
|
|
782
|
+
)
|
|
783
|
+
return 1
|
|
784
|
+
version = args.version or f"scrape-{today}"
|
|
785
|
+
else:
|
|
786
|
+
if args.seed is None:
|
|
787
|
+
print(
|
|
788
|
+
"Error: pass --seed <markdown-feed> or --fetch for a live scrape",
|
|
789
|
+
file=sys.stderr,
|
|
790
|
+
)
|
|
791
|
+
return 2
|
|
792
|
+
if not args.seed.is_file():
|
|
793
|
+
print(f"Error: seed file not found: {args.seed}", file=sys.stderr)
|
|
794
|
+
return 2
|
|
795
|
+
text = feed_from_text(args.seed.read_text(encoding="utf-8"))
|
|
796
|
+
version = args.version or f"seed-{today}"
|
|
797
|
+
|
|
798
|
+
features, by_feature, warnings = parse_markdown(text)
|
|
799
|
+
for w in warnings:
|
|
800
|
+
print(f"WARN: {w}", file=sys.stderr)
|
|
801
|
+
|
|
802
|
+
err = sanity_check(features, by_feature)
|
|
803
|
+
if err:
|
|
804
|
+
print(f"Error: {err}", file=sys.stderr)
|
|
805
|
+
return 1
|
|
806
|
+
|
|
807
|
+
existing = load_existing_catalog(args.out)
|
|
808
|
+
live_count = len(features)
|
|
809
|
+
if do_merge:
|
|
810
|
+
existing_count = existing[2] if existing else None
|
|
811
|
+
if live_parse_too_thin(live_count, existing_count):
|
|
812
|
+
print(
|
|
813
|
+
f"Error: live parse is too thin to merge "
|
|
814
|
+
f"({live_count} features; existing={existing_count}). "
|
|
815
|
+
"Keeping the current cache.",
|
|
816
|
+
file=sys.stderr,
|
|
817
|
+
)
|
|
818
|
+
return 1
|
|
819
|
+
if existing:
|
|
820
|
+
features, by_feature = merge_catalogs(
|
|
821
|
+
existing[0], existing[1], features, by_feature
|
|
822
|
+
)
|
|
823
|
+
print(
|
|
824
|
+
f"Merged live parse ({live_count}) into existing cache "
|
|
825
|
+
f"({existing[2]}) → {len(features)} features",
|
|
826
|
+
file=sys.stderr,
|
|
827
|
+
)
|
|
828
|
+
elif args.fetch and live_parse_too_thin(live_count, None):
|
|
829
|
+
print(
|
|
830
|
+
f"Error: live scrape featureCount {live_count} looks low. Not writing.",
|
|
831
|
+
file=sys.stderr,
|
|
832
|
+
)
|
|
833
|
+
return 1
|
|
834
|
+
|
|
835
|
+
emit(
|
|
836
|
+
features,
|
|
837
|
+
by_feature,
|
|
838
|
+
args.out,
|
|
839
|
+
version=version,
|
|
840
|
+
fetched_at=today,
|
|
841
|
+
source_url=source_url,
|
|
842
|
+
)
|
|
843
|
+
print(f"OK: {len(features)} features, {len(by_feature)} settings maps, version={version}")
|
|
844
|
+
return 0
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
if __name__ == "__main__":
|
|
848
|
+
raise SystemExit(main())
|