First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce manifest becomes shopdb data. P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its 0001 baseline really creates the tables. P1a model: one wide manifestentries table + entrytype discriminator (not STI, not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value filter child tables, inusechecks + processes, immutable manifestpublishedversions (frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases (mirror of the engine lib's alias graph). regvalue stored as its raw JSON literal so DWord typing survives. P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into draft rows and rebuild the JSON verbatim from rows in sortorder. P1d parity harness (GATE A): filters.py mirrors the engine's four filter functions + alias graph; parity.py proves import+export is behaviorally lossless (field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT byte-diffing. Verified PASS against all 11 real reference manifests (64 entries) and a synthetic site-neutral fixture covering every type/filter (the CI gate). First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/ export-to-share), CLI (parity, import-share, publish, export-share), and the client endpoint GET /api/geenforce/manifest serving the current published snapshot (never the draft) with ETag/304. Split permissions geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope). Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin service endpoints authorize a scoped managed token without importing core token internals. Documented in PLUGIN-HOOKS.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
95 lines
3.6 KiB
Python
95 lines
3.6 KiB
Python
"""P1 behavioral-parity harness (Gate A).
|
|
|
|
Proves the model is lossless WITHOUT byte-diffing: for each manifest, import it
|
|
into in-memory rows and render it back out, then check that
|
|
|
|
1. every entry is field-for-field identical (engine-relevant fields only,
|
|
dropping _comment and key order), in the same order, AND
|
|
2. the same entries fire in the same order for a set of machine-profile
|
|
fixtures, using the same filter logic the engine uses (filters.py).
|
|
|
|
Runs with no database (build_scope produces detached ORM objects whose
|
|
relationship lists are read directly), so it is cheap to re-run and wrap in CI.
|
|
Output is one readable line per scope.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
from .importer import build_scope
|
|
from .serializer import scope_to_manifest, canonical_entry
|
|
from .filters import applicable_entry_names
|
|
|
|
_FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'parityfixtures.json')
|
|
|
|
|
|
def load_fixtures():
|
|
with open(_FIXTURES_PATH) as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def check_scope(scopename, phase, original, fixtures):
|
|
"""Compare an original manifest against its import+export round-trip."""
|
|
rebuilt = scope_to_manifest(build_scope(scopename, phase, original))
|
|
orig_apps = original.get('Applications') or []
|
|
rebuilt_apps = rebuilt.get('Applications') or []
|
|
|
|
# Check 1: field-identical, order-preserving.
|
|
identical = 0
|
|
firstdiff = None
|
|
if len(orig_apps) != len(rebuilt_apps):
|
|
firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}')
|
|
for i in range(min(len(orig_apps), len(rebuilt_apps))):
|
|
co = canonical_entry(orig_apps[i])
|
|
cr = canonical_entry(rebuilt_apps[i])
|
|
if co == cr:
|
|
identical += 1
|
|
elif firstdiff is None:
|
|
name = orig_apps[i].get('Name', f'#{i}')
|
|
diffkeys = sorted(
|
|
k for k in set(co) | set(cr) if co.get(k) != cr.get(k))
|
|
firstdiff = f'entry "{name}" differs on {diffkeys}'
|
|
|
|
# Check 2: same entries fire in the same order for each profile.
|
|
profiles_same = 0
|
|
for profile in fixtures:
|
|
if (applicable_entry_names(orig_apps, profile)
|
|
== applicable_entry_names(rebuilt_apps, profile)):
|
|
profiles_same += 1
|
|
elif firstdiff is None:
|
|
firstdiff = f'filter mismatch for profile {profile.get("label")}'
|
|
|
|
passed = (identical == len(orig_apps) == len(rebuilt_apps)
|
|
and profiles_same == len(fixtures))
|
|
return {
|
|
'scopename': scopename,
|
|
'phase': phase,
|
|
'entries_total': len(orig_apps),
|
|
'entries_identical': identical,
|
|
'profiles_total': len(fixtures),
|
|
'profiles_same': profiles_same,
|
|
'passed': passed,
|
|
'firstdiff': firstdiff,
|
|
}
|
|
|
|
|
|
def run_parity(manifests, fixtures=None):
|
|
"""Run parity over a list of (scopename, phase, manifest_dict). Returns
|
|
(results, all_passed)."""
|
|
fixtures = fixtures if fixtures is not None else load_fixtures()
|
|
results = [check_scope(name, phase, manifest, fixtures)
|
|
for name, phase, manifest in manifests]
|
|
return results, all(r['passed'] for r in results)
|
|
|
|
|
|
def format_result(result):
|
|
"""One human-readable line for a scope result."""
|
|
status = 'PASS' if result['passed'] else 'FAIL'
|
|
line = (f"scope {result['scopename']:<28} "
|
|
f"entries {result['entries_identical']}/{result['entries_total']} identical "
|
|
f"profiles {result['profiles_same']}/{result['profiles_total']} same-fire "
|
|
f"{status}")
|
|
if not result['passed'] and result['firstdiff']:
|
|
line += f"\n -> {result['firstdiff']}"
|
|
return line
|