mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
feat(catalog-config): mirror manifest v2 schema in validator + site
Phase D of v2.3 template configuration — closes the loop between the Swift app and the catalog pipeline. Authors can now ship schemaful bundles; the Python validator enforces the same invariants the Swift installer does; the catalog site displays the schema so visitors see what they'll need to configure before installing. Python validator (tools/build-catalog.py): - SUPPORTED_SCHEMA_VERSIONS accepts both 1 and 2 (v1 bundles are unchanged; v2 adds optional manifest.config). - New _validate_config_schema function mirrors the Swift ProjectConfigService.validateSchema rules: unique keys, supported types, enum option presence + unique values, list itemType == "string", secret-field cannot declare a default, modelRecommendation.preferred non-empty when present. - _validate_contents_claim cross-checks contents.config (field count) against config.schema actual length — mismatch refused. - TemplateRecord.to_catalog_entry exposes `config` in catalog.json so the site can render the schema. - render_site copies each bundle's template.json to the detail dir as manifest.json (only when the manifest has a config block — keeps the served tree lean and makes "no manifest.json" a meaningful 404 signal in the frontend). - catalog.json's own schemaVersion stays at 1 (independent of per- template manifest schemaVersion). Python tests (tools/test_build_catalog.py): 8 new cases in a new ConfigSchemaValidationTests suite — accepts schemaful bundle, rejects duplicate keys, rejects secret-with-default, rejects enum-without- options, rejects unsupported field type, rejects contents.config count mismatch, rejects unsupported list itemType, legacy v1 manifests pass unchanged. 24/24 Python tests total. Site (site/widgets.js): - New renderConfigSchema(container, config) — mirrors the display on the Scarf install preview. Renders each field as a <dt>/<dd> pair with type + required badges; enum shows choice labels; list fields show min/max bounds; string fields show pattern/length; secret fields get a "Stored in Keychain" reassurance. Optional modelRecommendation panel at the bottom with preferred + rationale + alternatives. - The renderer is display-only — the site never collects values; that's the Scarf app's job. template.html.tmpl adds a #config-schema <section>. The inline script fetches manifest.json from the detail dir; on success hands the config block to ScarfWidgets.renderConfigSchema; on 404 (schema-less templates) silently leaves the section empty. CSS in styles.css adds a config-schema panel matching the accent-green aesthetic. 24/24 Python + 50/50 Swift tests pass. site-status-checker still renders correctly (schema-less; manifest.json isn't copied for it). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -335,6 +335,194 @@ class ValidationTests(unittest.TestCase):
|
||||
return records, errors
|
||||
|
||||
|
||||
class ConfigSchemaValidationTests(unittest.TestCase):
|
||||
"""Mirrors the Swift `ProjectConfigServiceTests` schema-validation
|
||||
suite. Every rule enforced on the Swift side must be enforced on
|
||||
the Python side — schema drift is a catastrophic failure for the
|
||||
catalog (CI would accept bundles the app later refuses at install)."""
|
||||
|
||||
def setUp(self):
|
||||
self._dir = tempfile.TemporaryDirectory()
|
||||
self.repo = make_fake_repo(Path(self._dir.name))
|
||||
self.addCleanup(self._dir.cleanup)
|
||||
|
||||
def _make_schema_manifest(self, fields, cron: int = 0):
|
||||
"""Convenience — build a v2 manifest with the given config fields."""
|
||||
return {
|
||||
"schemaVersion": 2,
|
||||
"id": "tester/configured",
|
||||
"name": "Configured",
|
||||
"version": "1.0.0",
|
||||
"description": "test",
|
||||
"contents": {
|
||||
"dashboard": True,
|
||||
"agentsMd": True,
|
||||
"cron": cron,
|
||||
"config": len(fields),
|
||||
},
|
||||
"config": {"schema": fields},
|
||||
}
|
||||
|
||||
def test_accepts_schemaful_bundle(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "name", "type": "string", "label": "Name", "required": True},
|
||||
{"key": "enabled", "type": "bool", "label": "Enabled"},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "configured",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# readme",
|
||||
"AGENTS.md": b"# agents",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
records = []
|
||||
errors = []
|
||||
for tdir in build_catalog._iter_templates(self.repo):
|
||||
rec, errs = build_catalog.validate_template(tdir)
|
||||
errors.extend(errs)
|
||||
if rec is not None:
|
||||
records.append(rec)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(records[0].manifest["schemaVersion"], 2)
|
||||
|
||||
def test_rejects_duplicate_keys(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "same", "type": "string", "label": "A"},
|
||||
{"key": "same", "type": "bool", "label": "B"},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "dup",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("duplicate key" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_secret_with_default(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{
|
||||
"key": "api_key", "type": "secret", "label": "API Key",
|
||||
"required": True, "default": "sk-leaked-in-template"
|
||||
},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "secret-default",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("must not declare a default" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_enum_without_options(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "choice", "type": "enum", "label": "Choice", "options": []},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "enum-empty",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("at least one option" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_unsupported_field_type(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "wat", "type": "hologram", "label": "W"},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "bad-type",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("unsupported type" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_contents_config_count_mismatch(self):
|
||||
# Schema has 1 field; contents.config claims 2.
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "only", "type": "string", "label": "Only"},
|
||||
])
|
||||
manifest["contents"]["config"] = 2
|
||||
make_template_dir(
|
||||
self.repo, "tester", "mismatch",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("contents.config=2" in str(e) for e in errors), errors)
|
||||
|
||||
def test_rejects_unsupported_list_item_type(self):
|
||||
manifest = self._make_schema_manifest([
|
||||
{"key": "items", "type": "list", "label": "Items", "itemType": "number"},
|
||||
])
|
||||
make_template_dir(
|
||||
self.repo, "tester", "list-type",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertTrue(any("unsupported itemType" in str(e) for e in errors), errors)
|
||||
|
||||
def test_accepts_schemaless_v1_manifest_unchanged(self):
|
||||
# Pre-v2.3 bundles without any config block should keep working.
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"id": "tester/legacy",
|
||||
"name": "Legacy",
|
||||
"version": "1.0.0",
|
||||
"description": "no config",
|
||||
"contents": {"dashboard": True, "agentsMd": True},
|
||||
}
|
||||
make_template_dir(
|
||||
self.repo, "tester", "legacy",
|
||||
manifest=manifest,
|
||||
bundle_files={
|
||||
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||
},
|
||||
)
|
||||
errors = self._collect_errors()
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def _collect_errors(self):
|
||||
errors = []
|
||||
for tdir in build_catalog._iter_templates(self.repo):
|
||||
rec, errs = build_catalog.validate_template(tdir)
|
||||
errors.extend(errs)
|
||||
if rec is not None:
|
||||
errors.extend(build_catalog._check_staging_matches_bundle(rec))
|
||||
return errors
|
||||
|
||||
|
||||
class CatalogJsonTests(unittest.TestCase):
|
||||
"""Shape of the emitted catalog.json must stay stable — the site's
|
||||
widgets.js reads these fields by name."""
|
||||
|
||||
Reference in New Issue
Block a user