@@ -2462,6 +2462,151 @@ def check_sys_19():
24622462 return seen .failures () + failures , seen .notes ()
24632463
24642464
2465+ # ---------------------------------------------------------------------------
2466+ # SYS-20 -- a fork leaves upstream's manifest keys as it last merged them
2467+ # ---------------------------------------------------------------------------
2468+
2469+ # A fork appends under `stacks:`, `platform:` and keys of its own, and never
2470+ # edits the keys upstream owns. An edit there is what turns the fork's next
2471+ # upstream merge into a conflict, and nothing else notices it until then.
2472+ #
2473+ # In a fork, SAIT_UPSTREAM_REF names a ref holding upstream's history, and
2474+ # the baseline is the revision the fork last merged: the merge base of HEAD
2475+ # and that ref. Upstream sets no ref, so the manifest is compared with
2476+ # itself and the check passes trivially. Because that run reads no edit,
2477+ # planted manifest pairs exercise the failing path on every run.
2478+
2479+ UPSTREAM_KEYS = ("core" , "base" , "backend" , "frontend" )
2480+ UPSTREAM_REF_ENV = "SAIT_UPSTREAM_REF"
2481+
2482+
2483+ def upstream_key_edits (was , now ):
2484+ """Each edit to an upstream-owned manifest key, as one finding."""
2485+ found = []
2486+ for key in UPSTREAM_KEYS :
2487+ before , after = was .get (key ) or [], now .get (key ) or []
2488+ if key == "core" :
2489+ if before != after :
2490+ found .append (f"core: { before } became { after } " )
2491+ continue
2492+ old = {e ["id" ]: e for e in before }
2493+ new = {e ["id" ]: e for e in after }
2494+ for eid in sorted (set (old ) - set (new )):
2495+ found .append (f"{ key } : { eid } removed" )
2496+ for eid in sorted (set (new ) - set (old )):
2497+ found .append (f"{ key } : { eid } added" )
2498+ for eid in sorted (set (old ) & set (new )):
2499+ fields = sorted (k for k in set (old [eid ]) | set (new [eid ])
2500+ if old [eid ].get (k ) != new [eid ].get (k ))
2501+ if fields :
2502+ found .append (f"{ key } : { eid } changed ({ ', ' .join (fields )} )" )
2503+ return found
2504+
2505+
2506+ OWNED_BASE = """core: [base-a]
2507+ base:
2508+ - id: base-a
2509+ file: templates/core/quality.md
2510+ backend:
2511+ - id: backend-a
2512+ file: templates/backend/http.md
2513+ depends_on: [base-a]
2514+ frontend: []
2515+ stacks:
2516+ - id: stack-a
2517+ file: templates/stack/a.md
2518+ depends_on: [backend-a]
2519+ """
2520+
2521+ # Each case is a fork's manifest against OWNED_BASE, and the findings the
2522+ # check owes it. The appends are the control: a check that flagged them
2523+ # would fail every fork that did what the contract allows.
2524+ OWNERSHIP_CASES = [
2525+ ("a stack and a platform appended, and a key of the fork's own" ,
2526+ OWNED_BASE + """ - id: stack-b
2527+ file: templates/stack/b.md
2528+ platform:
2529+ - id: platform-b
2530+ file: templates/platform/b.md
2531+ fork:
2532+ - id: fork-note
2533+ """ , []),
2534+ ("the core tier edited" ,
2535+ OWNED_BASE .replace ("core: [base-a]" , "core: [base-a, backend-a]" ),
2536+ ["core: ['base-a'] became ['base-a', 'backend-a']" ]),
2537+ ("an upstream entry changed" ,
2538+ OWNED_BASE .replace ("depends_on: [base-a]" , "depends_on: []" ),
2539+ ["backend: backend-a changed (depends_on)" ]),
2540+ ("an entry added under an upstream key" ,
2541+ OWNED_BASE .replace ("frontend: []" , """frontend:
2542+ - id: frontend-b
2543+ file: templates/frontend/b.md""" ),
2544+ ["frontend: frontend-b added" ]),
2545+ ("an upstream entry removed" ,
2546+ OWNED_BASE .replace (""" - id: backend-a
2547+ file: templates/backend/http.md
2548+ depends_on: [base-a]
2549+ """ , "" ),
2550+ ["backend: backend-a removed" ]),
2551+ ]
2552+
2553+
2554+ def check_sys_20 ():
2555+ if not HAS_YAML :
2556+ return [MISSING_YAML ]
2557+
2558+ seen = Inspected ()
2559+ failures = []
2560+
2561+ seen .count ("planted fork manifests" , OWNERSHIP_CASES )
2562+ base = yaml .safe_load (OWNED_BASE )
2563+ for label , text , want in OWNERSHIP_CASES :
2564+ got = upstream_key_edits (base , yaml .safe_load (text ))
2565+ if got != want :
2566+ failures .append (f" planted, { label } : found { got } , owed { want } " )
2567+
2568+ manifest = os .path .join (ROOT , "templates" , "manifest.yaml" )
2569+ with io .open (manifest , encoding = "utf-8" ) as handle :
2570+ now_text = handle .read ()
2571+
2572+ ref = os .environ .get (UPSTREAM_REF_ENV )
2573+ if ref :
2574+ merged = subprocess .run (["git" , "merge-base" , "HEAD" , ref ],
2575+ cwd = ROOT , capture_output = True , text = True )
2576+ if merged .returncode != 0 :
2577+ failures .append (
2578+ f" { UPSTREAM_REF_ENV } ={ ref } shares no history with HEAD, so "
2579+ f"the revision the fork last merged is unknown: "
2580+ f"{ merged .stderr .strip ()} "
2581+ )
2582+ return seen .failures () + failures , seen .notes ()
2583+ revision = merged .stdout .strip ()
2584+ shown = subprocess .run (
2585+ ["git" , "show" , f"{ revision } :templates/manifest.yaml" ],
2586+ cwd = ROOT , capture_output = True , text = True , encoding = "utf-8" )
2587+ if shown .returncode != 0 :
2588+ failures .append (f" the manifest at { revision [:12 ]} could not be "
2589+ f"read: { shown .stderr .strip ()} " )
2590+ return seen .failures () + failures , seen .notes ()
2591+ was_text = shown .stdout
2592+ basis = (f" baseline: the merge base { revision [:12 ]} of HEAD and "
2593+ f"{ ref } " )
2594+ else :
2595+ was_text = now_text
2596+ basis = (f" baseline: { UPSTREAM_REF_ENV } is unset, so this is "
2597+ f"upstream and the manifest is compared with itself" )
2598+
2599+ was , now = yaml .safe_load (was_text ), yaml .safe_load (now_text )
2600+ seen .count ("upstream keys compared" ,
2601+ [k for k in UPSTREAM_KEYS if k in was ])
2602+ for finding in upstream_key_edits (was , now ):
2603+ failures .append (
2604+ f" { finding } : a fork appends under stacks:, platform: and its "
2605+ f"own keys, and leaves upstream's as it last merged them"
2606+ )
2607+
2608+ return seen .failures () + failures , seen .notes () + [basis ]
2609+
24652610CHECKS = [
24662611 {"id" : "SYS-01" , "spec" : "SAIT-SMK-SYS-01-001A" ,
24672612 "title" : "DEPENDS ON paths resolve to existing files" , "fn" : check_sys_01 },
@@ -2545,6 +2690,9 @@ def check_sys_19():
25452690 {"id" : "SYS-19" , "spec" : "SAIT-SMK-SYS-19-001A" ,
25462691 "title" : "resolve.py names a missing dependency and a dependency cycle" ,
25472692 "fn" : check_sys_19 },
2693+ {"id" : "SYS-20" , "spec" : "SAIT-SMK-SYS-20-001A" ,
2694+ "title" : "A fork leaves upstream's manifest keys as it last merged them" ,
2695+ "fn" : check_sys_20 },
25482696]
25492697
25502698
0 commit comments