@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.
Files changed (118) hide show
  1. package/README.md +55 -5
  2. package/package.json +1 -1
  3. package/skills/agentforce-offline-experimentation-run/SKILL.md +14 -22
  4. package/skills/agentforce-offline-experimentation-run/scripts/bt_compare.py +81 -0
  5. package/skills/agentforce-offline-experimentation-run/scripts/bt_fetch.py +358 -0
  6. package/skills/agentforce-offline-experimentation-run/scripts/bt_patch_agent.py +110 -0
  7. package/skills/agentforce-offline-experimentation-run/scripts/btlib/__init__.py +34 -0
  8. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentpatch.py +483 -0
  9. package/skills/agentforce-offline-experimentation-run/scripts/btlib/agentsrc.py +565 -0
  10. package/skills/agentforce-offline-experimentation-run/scripts/btlib/cache.py +95 -0
  11. package/skills/agentforce-offline-experimentation-run/scripts/btlib/compare.py +617 -0
  12. package/skills/agentforce-offline-experimentation-run/scripts/btlib/fetch.py +1012 -0
  13. package/skills/agentforce-offline-experimentation-run/scripts/btlib/judge.py +148 -0
  14. package/skills/agentforce-offline-experimentation-run/scripts/btlib/messages.py +92 -0
  15. package/skills/agentforce-offline-experimentation-run/scripts/btlib/patch.py +327 -0
  16. package/skills/agentforce-offline-experimentation-run/scripts/btlib/redact.py +40 -0
  17. package/skills/agentforce-offline-experimentation-run/scripts/btlib/results.py +288 -0
  18. package/skills/agentforce-offline-experimentation-run/scripts/btlib/scoring.py +340 -0
  19. package/skills/agentforce-offline-experimentation-run/scripts/btlib/sfap.py +238 -0
  20. package/skills/agentforce-offline-experimentation-run/scripts/btlib/splits.py +136 -0
  21. package/skills/agentforce-offline-experimentation-run/scripts/btlib/stdm.py +98 -0
  22. package/skills/agentforce-offline-experimentation-run/scripts/btlib/tokens.py +173 -0
  23. package/skills/agentforce-offline-experimentation-run/scripts/btlib/units.py +199 -0
  24. package/skills/agentforce-offline-experimentation-run/scripts/btlib/versions.py +194 -0
  25. package/skills/agentforce-offline-experimentation-run/scripts/build_report.py +366 -378
  26. package/skills/agentforce-offline-experimentation-run/scripts/resolve-token.sh +14 -4
  27. package/skills/agentforce-offline-experimentation-run/scripts/sfap_replay.py +472 -796
  28. package/skills/dx-lifecycle-center/SKILL.md +5 -2
  29. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/connect-connected.json +65 -0
  30. package/skills/dx-lifecycle-center/assets/widgets/connect-connected/schema.json +32 -0
  31. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/connect-sign-in.json +57 -0
  32. package/skills/dx-lifecycle-center/assets/widgets/connect-sign-in/schema.json +21 -0
  33. package/skills/dx-lifecycle-center/examples/workflows.md +20 -6
  34. package/skills/dx-lifecycle-center/references/connect-org.md +21 -2
  35. package/skills/dx-lifecycle-center/references/deploy.md +2 -2
  36. package/skills/dx-lifecycle-center/references/list-orgs.md +1 -1
  37. package/skills/dx-lifecycle-center/scripts/card-policy.jq +71 -0
  38. package/skills/dx-lifecycle-center/scripts/fill-widget.jq +29 -0
  39. package/skills/dx-lifecycle-center/scripts/widget.sh +28 -0
  40. package/skills/dx-org-devhub-configure/SKILL.md +85 -84
  41. package/skills/dx-org-devhub-configure/examples/status-output.md +25 -2
  42. package/skills/dx-org-devhub-configure/scripts/devhub.sh +265 -140
  43. package/skills/dx-org-devhub-configure/scripts/test-devhub.sh +100 -15
  44. package/skills/dx-packaging-move-to-2gp-migrate/SKILL.md +89 -0
  45. package/skills/dx-packaging-move-to-2gp-migrate/references/api-operations.md +164 -0
  46. package/skills/dx-scratch-def-configure/SKILL.md +163 -0
  47. package/skills/dx-scratch-def-configure/examples/confirm-card.md +53 -0
  48. package/skills/dx-scratch-def-configure/examples/report-recipes.md +100 -0
  49. package/skills/dx-scratch-def-configure/references/features.json +3270 -0
  50. package/skills/dx-scratch-def-configure/references/project-scratch-def.schema.json +1841 -0
  51. package/skills/dx-scratch-def-configure/references/settings-map.json +682 -0
  52. package/skills/dx-scratch-def-configure/scripts/lib_scratch_def.py +1306 -0
  53. package/skills/dx-scratch-def-configure/scripts/requirements.txt +3 -0
  54. package/skills/dx-scratch-def-configure/scripts/scrape_config_values.py +848 -0
  55. package/skills/education-cloud-domain-configure/SKILL.md +1 -1
  56. package/skills/education-cloud-multi-campus-configure/SKILL.md +1 -1
  57. package/skills/education-cloud-student-recruitment-agent-configure/SKILL.md +1 -1
  58. package/skills/experience-design-validate/SKILL.md +1 -1
  59. package/skills/experience-search-coordinate/SKILL.md +18 -12
  60. package/skills/experience-search-coordinate/references/content-route.md +79 -1
  61. package/skills/field-service-data-capture-migrate/SKILL.md +1 -1
  62. package/skills/platform-apex-generate/SKILL.md +14 -11
  63. package/skills/platform-apex-test-generate/SKILL.md +2 -1
  64. package/skills/platform-datamask-run/SKILL.md +52 -71
  65. package/skills/platform-datamask-run/references/api-surface.md +10 -4
  66. package/skills/platform-datamask-run/references/policy-authoring.md +56 -7
  67. package/skills/platform-datamask-run/references/run-and-abort.md +17 -10
  68. package/skills/platform-datamask-run/scripts/poll-job.sh +15 -25
  69. package/skills/platform-dataspace-access-configure/SKILL.md +19 -1
  70. package/skills/platform-metadata-discovery-get/SKILL.md +185 -0
  71. package/skills/platform-metadata-discovery-get/references/api-map.md +25 -0
  72. package/skills/platform-metadata-discovery-get/references/h360-routes.md +30 -0
  73. package/skills/platform-metadata-discovery-get/references/scoped-soql.md +52 -0
  74. package/skills/platform-metadata-grounding-get/SKILL.md +170 -0
  75. package/skills/platform-policy-rule-generate/SKILL.md +84 -232
  76. package/skills/platform-policy-rule-generate/references/condition-model.md +47 -0
  77. package/skills/platform-policy-rule-generate/references/condition-templates.md +89 -0
  78. package/skills/platform-policy-rule-generate/references/conjunction-compiler.md +25 -0
  79. package/skills/platform-policy-rule-generate/references/conjunction-expression.md +22 -0
  80. package/skills/platform-policy-rule-generate/references/consumer-all.md +9 -0
  81. package/skills/platform-policy-rule-generate/references/consumer-datacloud-ui.md +91 -0
  82. package/skills/platform-policy-rule-generate/references/deploy-errors.md +24 -36
  83. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/package.xml +12 -0
  84. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitionSets/ExistsJoinRLS.policyRuleDefinitionSet +6 -0
  85. package/skills/platform-policy-rule-generate/references/examples/ExistsJoinRLS/policyRuleDefinitions/ExistsJoinRLS_Rule_1.policyRuleDefinition +91 -0
  86. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/package.xml +12 -0
  87. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitionSets/FieldReadDeny.policyRuleDefinitionSet +6 -0
  88. package/skills/platform-policy-rule-generate/references/examples/FieldReadDeny/policyRuleDefinitions/FieldReadDeny_Rule_1.policyRuleDefinition +72 -0
  89. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/package.xml +12 -0
  90. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitionSets/LastCharactersFieldMasking.policyRuleDefinitionSet +6 -0
  91. package/skills/platform-policy-rule-generate/references/examples/LastCharactersFieldMasking/policyRuleDefinitions/LastCharactersFieldMasking_Rule_1.policyRuleDefinition +77 -0
  92. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/package.xml +12 -0
  93. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitionSets/PrincipalFieldMatchRLS.policyRuleDefinitionSet +6 -0
  94. package/skills/platform-policy-rule-generate/references/examples/PrincipalFieldMatchRLS/policyRuleDefinitions/PrincipalFieldMatchRLS_Rule_1.policyRuleDefinition +77 -0
  95. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/package.xml +12 -0
  96. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitionSets/TaggedObjectAccess.policyRuleDefinitionSet +4 -0
  97. package/skills/platform-policy-rule-generate/references/examples/TaggedObjectAccess/policyRuleDefinitions/TaggedObjectAccess_Rule_1.policyRuleDefinition +65 -0
  98. package/skills/platform-policy-rule-generate/references/headless-transport.md +87 -0
  99. package/skills/platform-policy-rule-generate/references/policy-schema-full.md +17 -10
  100. package/skills/platform-policy-rule-generate/references/policy-shapes.md +74 -0
  101. package/skills/platform-policy-rule-generate/references/principal-expression.md +31 -0
  102. package/skills/platform-policy-rule-generate/references/reference-resolution.md +51 -0
  103. package/skills/platform-policy-rule-generate/references/resource-expression.md +48 -0
  104. package/skills/platform-policy-rule-generate/references/runtime-context.md +9 -0
  105. package/skills/platform-policy-rule-generate/references/templates-access.md +11 -9
  106. package/skills/platform-policy-rule-generate/references/templates-advanced.md +4 -2
  107. package/skills/platform-policy-rule-generate/references/templates-record.md +37 -22
  108. package/skills/platform-policy-rule-generate/references/templates-transform.md +10 -8
  109. package/skills/platform-policy-rule-generate/references/templates.md +2 -2
  110. package/skills/platform-policy-rule-generate/references/ui-compatibility.md +35 -17
  111. package/skills/platform-report-generate/SKILL.md +9 -4
  112. package/skills/platform-report-generate/references/chart-types.md +26 -2
  113. package/skills/platform-report-generate/references/column-names.md +43 -0
  114. package/skills/platform-report-generate/references/errors-and-troubleshooting.md +1 -1
  115. package/skills/platform-report-generate/references/verification-checklist.md +10 -2
  116. package/skills/service-digital-engagement-channel-configure/SKILL.md +7 -5
  117. package/skills/service-digital-engagement-channel-configure/assets/messaging_channel_template.xml +33 -0
  118. 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*(?:&lt;value&gt;|<value>| &lt;value&gt;))?\*\*"
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*(?:&lt;value&gt;|<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}:&lt;value&gt;**" 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())