@svayam-opensource/prj 0.5.1

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.
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env bash
2
+ # Script: sync-from-publish
3
+ # Purpose: Pull universal framework updates from `publish` into `main`,
4
+ # preserving main's private overlay (org-config.yaml, registry.yaml,
5
+ # projects/). Direction A: framework files contain no placeholders,
6
+ # so this is a plain merge — no re-substitution step.
7
+ #
8
+ # Usage: bash scripts/sync-from-publish.sh [--dry-run] [--no-push]
9
+ #
10
+ # Flags:
11
+ # --dry-run Show what would be merged without making any changes.
12
+ # --no-push Do the merge + validation locally; do not push to origin/main.
13
+ #
14
+ # Privacy: NEVER syncs main → publish. One-way (publish → main).
15
+ #
16
+ # Strategy:
17
+ # 1. Verify on main, clean, fast-forwardable from origin.
18
+ # 2. Show commits to be merged; confirm (skip if --dry-run).
19
+ # 3. Create ephemeral test branch test-sync-from-publish from main.
20
+ # 4. Merge publish with -X theirs (auto-prefer publish for content conflicts).
21
+ # 5. Restore main's private overlay (org-config.yaml, registry.yaml, projects/)
22
+ # from main's pre-merge state.
23
+ # 6. Run validators — fail if any placeholders leaked (framework files must
24
+ # stay placeholder-free; validator's placeholder check is always-on).
25
+ # 7. On pass: fast-forward main to test branch tip, push, delete test branch.
26
+ # 8. On fail: discard test branch, restore main, exit non-zero.
27
+
28
+ set -euo pipefail
29
+ source "$(dirname "$0")/lib.sh"
30
+ load_config
31
+
32
+ DRY_RUN=false
33
+ NO_PUSH=false
34
+
35
+ for arg in "$@"; do
36
+ case "$arg" in
37
+ --dry-run) DRY_RUN=true ;;
38
+ --no-push) NO_PUSH=true ;;
39
+ --allow-downgrade) export ALLOW_DOWNGRADE=true ;;
40
+ -h|--help)
41
+ grep '^# ' "$0" | sed 's/^# //;s/^#//'
42
+ exit 0
43
+ ;;
44
+ *) hard_stop "Unknown flag: $arg (see --help)" ;;
45
+ esac
46
+ done
47
+
48
+ cd "$REPO_ROOT"
49
+
50
+ # ── Pre-conditions ────────────────────────────────────────────────────────────
51
+
52
+ CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
53
+ [[ "$CURRENT_BRANCH" == "$DEFAULT_BRANCH" ]] \
54
+ || hard_stop "Run from '$DEFAULT_BRANCH' (currently on '$CURRENT_BRANCH')."
55
+
56
+ [[ -z "$(git status --porcelain)" ]] \
57
+ || hard_stop "Uncommitted changes present. Stash or commit first."
58
+
59
+ # ── Fetch latest ──────────────────────────────────────────────────────────────
60
+
61
+ echo "=== sync-from-publish"
62
+ $DRY_RUN && echo " (dry-run mode — no changes will be made)"
63
+ $NO_PUSH && echo " (--no-push — will merge locally but not push)"
64
+ echo ""
65
+
66
+ info "Fetching latest..."
67
+ git fetch origin "$DEFAULT_BRANCH"
68
+ git fetch origin publish
69
+
70
+ if ! git pull --ff-only origin "$DEFAULT_BRANCH"; then
71
+ hard_stop "Cannot fast-forward local '$DEFAULT_BRANCH' from origin."
72
+ fi
73
+
74
+ # ── Identify commits to sync ──────────────────────────────────────────────────
75
+
76
+ COMMITS_TO_SYNC=$(git rev-list --count "$DEFAULT_BRANCH..origin/publish" 2>/dev/null || echo 0)
77
+
78
+ if [[ "$COMMITS_TO_SYNC" -eq 0 ]]; then
79
+ echo "✓ '$DEFAULT_BRANCH' is already up to date with publish."
80
+ exit 0
81
+ fi
82
+
83
+ # ── Framework version guard: never let an OLDER publish overwrite main ────────
84
+ PUB_VER="$(git show origin/publish:framework/VERSION 2>/dev/null | tr -d '[:space:]')"
85
+ CUR_VER="$(git show "$DEFAULT_BRANCH:framework/VERSION" 2>/dev/null | tr -d '[:space:]')"
86
+ [[ -n "$PUB_VER" ]] && info "framework: publish v$PUB_VER → $DEFAULT_BRANCH v$CUR_VER"
87
+ assert_no_framework_downgrade "$PUB_VER" "$CUR_VER" "sync-from-publish"
88
+
89
+ echo ""
90
+ echo "Commits on publish not yet on $DEFAULT_BRANCH ($COMMITS_TO_SYNC total):"
91
+ git log --oneline "$DEFAULT_BRANCH..origin/publish" | head -30
92
+ echo ""
93
+
94
+ if $DRY_RUN; then
95
+ echo ""
96
+ info "Dry-run: would merge the above commits into $DEFAULT_BRANCH using -X theirs,"
97
+ info " restore private overlay (org-config.yaml, registry.yaml, projects/),"
98
+ info " run validators,"
99
+ info " and (unless --no-push) push to origin/$DEFAULT_BRANCH."
100
+ exit 0
101
+ fi
102
+
103
+ confirm "Proceed with merge?"
104
+
105
+ # ── Test branch + merge ───────────────────────────────────────────────────────
106
+
107
+ TEST_BRANCH="test-sync-from-publish"
108
+ ORIGINAL_SHA=$(git rev-parse HEAD)
109
+
110
+ cleanup_on_fail() {
111
+ echo ""
112
+ warn "Sync failed — restoring '$DEFAULT_BRANCH' to pre-sync state."
113
+ git merge --abort 2>/dev/null || true
114
+ git checkout "$DEFAULT_BRANCH" 2>/dev/null || true
115
+ git reset --hard "$ORIGINAL_SHA" 2>/dev/null || true
116
+ git branch -D "$TEST_BRANCH" 2>/dev/null || true
117
+ }
118
+
119
+ if git rev-parse --verify "$TEST_BRANCH" &>/dev/null; then
120
+ warn "Stale test branch '$TEST_BRANCH' exists — deleting."
121
+ git branch -D "$TEST_BRANCH"
122
+ fi
123
+ git checkout -b "$TEST_BRANCH"
124
+
125
+ info "Merging publish with -X theirs (auto-prefers publish for conflicts)..."
126
+ if ! git merge -X theirs --no-edit -m "sync: publish → $DEFAULT_BRANCH" origin/publish; then
127
+ cleanup_on_fail
128
+ hard_stop "Merge failed despite -X theirs. Manual intervention required."
129
+ fi
130
+
131
+ # ── Restore private overlay from pre-merge main ───────────────────────────────
132
+
133
+ info "Restoring private overlay (org-config.yaml, registry.yaml, projects/)..."
134
+
135
+ # HEAD^1 is the first parent (pre-merge main); HEAD^2 is publish's tip.
136
+ # We want main's pre-merge content for these specific paths.
137
+ PRIVATE_PATHS=("org-config.yaml" "registry.yaml")
138
+ for path in "${PRIVATE_PATHS[@]}"; do
139
+ if git ls-tree HEAD^1 -- "$path" &>/dev/null; then
140
+ git checkout HEAD^1 -- "$path"
141
+ fi
142
+ done
143
+
144
+ # projects/ may exist on main only (publish has empty projects/)
145
+ if git ls-tree HEAD^1 -- projects/ &>/dev/null; then
146
+ # Remove any projects content that came from publish, then restore main's
147
+ git rm -rf --cached projects/ &>/dev/null || true
148
+ rm -rf projects
149
+ git checkout HEAD^1 -- projects/
150
+ fi
151
+
152
+ # Amend the merge commit with the overlay restorations.
153
+ if [[ -n "$(git diff --cached --name-only)" ]]; then
154
+ git commit --amend --no-edit
155
+ fi
156
+
157
+ # ── Validate the merged tree ──────────────────────────────────────────────────
158
+
159
+ VALIDATOR="$REPO_ROOT/scripts/validate/run.py"
160
+ if [[ -x "$VALIDATOR" ]]; then
161
+ echo ""
162
+ info "Running validators against merged tree..."
163
+ echo ""
164
+ if ! python3 "$VALIDATOR" "$REPO_ROOT"; then
165
+ echo ""
166
+ cleanup_on_fail
167
+ hard_stop "Validation FAILED — sync rolled back. '$DEFAULT_BRANCH' is unchanged."
168
+ fi
169
+ else
170
+ warn "Validator not found at $VALIDATOR — skipping post-merge validation."
171
+ fi
172
+
173
+ # ── Promote test branch to main and clean up ──────────────────────────────────
174
+
175
+ echo ""
176
+ info "Validation passed. Promoting to '$DEFAULT_BRANCH'..."
177
+ git checkout "$DEFAULT_BRANCH"
178
+ git merge --ff-only "$TEST_BRANCH"
179
+ git branch -d "$TEST_BRANCH"
180
+
181
+ # ── Push (unless --no-push) ───────────────────────────────────────────────────
182
+
183
+ if $NO_PUSH; then
184
+ echo ""
185
+ info "✓ Sync complete locally. NOT pushed (--no-push)."
186
+ info " When ready: git push origin $DEFAULT_BRANCH"
187
+ else
188
+ echo ""
189
+ info "Pushing to origin/$DEFAULT_BRANCH..."
190
+ git push origin "$DEFAULT_BRANCH"
191
+ echo ""
192
+ info "✓ Sync complete. '$DEFAULT_BRANCH' updated and pushed."
193
+ fi
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # Script: sync
3
+ # Purpose: Merges latest DEFAULT_BRANCH/base into active project branch on demand.
4
+ # Use mid-project to stay current without pausing/resuming.
5
+ # Usage: bash sync.sh <project_id>
6
+ # Compliance: C03 — encouraged but not mandatory (POL-122)
7
+
8
+ set -euo pipefail
9
+ source "$(dirname "$0")/lib.sh"
10
+ load_config
11
+
12
+ # ── Inputs ────────────────────────────────────────────────────────────────────
13
+
14
+ PROJECT_ID="${1:-}"
15
+ [[ -n "$PROJECT_ID" ]] || hard_stop "Usage: $0 <project_id>"
16
+
17
+ echo "=== sync: $PROJECT_ID"
18
+ echo ""
19
+
20
+ PROJECT_YAML=$(get_project_yaml "$PROJECT_ID")
21
+ check_project_exists "$PROJECT_ID"
22
+
23
+ # ── Pre-conditions ────────────────────────────────────────────────────────────
24
+
25
+ require_project_status "$PROJECT_YAML" "active"
26
+
27
+ CURRENT_USER=$(git config user.email 2>/dev/null || echo "")
28
+ ASSIGNED_TO=$(yaml_get "$PROJECT_YAML" "assigned_to") # display/audit cache
29
+ GH_PROJECT=$(yaml_get "$PROJECT_YAML" "github_project")
30
+ is_authorized_for_project "$GH_PROJECT" "$ASSIGNED_TO" \
31
+ || hard_stop "Not authorized to sync — '$CURRENT_USER' needs write access to the project's GitHub Project ($GH_PROJECT)."
32
+
33
+ BRANCH=$(project_branch_for_id "$PROJECT_ID")
34
+
35
+ echo "Checking for uncommitted changes..."
36
+ check_clean "$REPO_ROOT"
37
+ while IFS= read -r repo_url; do
38
+ REPO_DIR="$(repo_clone_dir "$PROJECT_ID" "$(get_repo_name "$repo_url")")"
39
+ [[ -e "$REPO_DIR/.git" ]] && check_clean "$REPO_DIR"
40
+ done < <(get_project_repos "$PROJECT_YAML")
41
+ info "All repos are clean."
42
+ echo ""
43
+
44
+ # ── Sync workspace repo ───────────────────────────────────────────────────────
45
+
46
+ echo "Syncing workspace repo: $DEFAULT_BRANCH → $BRANCH..."
47
+ cd "$REPO_ROOT"
48
+ git fetch origin "$DEFAULT_BRANCH"
49
+ git checkout "$BRANCH"
50
+ if ! git merge --no-edit "origin/$DEFAULT_BRANCH" 2>/dev/null; then
51
+ echo ""
52
+ echo "MERGE CONFLICT: $DEFAULT_BRANCH → $BRANCH in workspace repo."
53
+ echo "Resolve conflicts manually, commit, then re-run: bash sync.sh $PROJECT_ID"
54
+ exit 2
55
+ fi
56
+ git push origin "$BRANCH"
57
+ info "Workspace repo synced."
58
+
59
+ # ── Sync each code repo ───────────────────────────────────────────────────────
60
+
61
+ while IFS= read -r repo_url; do
62
+ REPO_NAME=$(get_repo_name "$repo_url")
63
+ REPO_DIR="$(repo_clone_dir "$PROJECT_ID" "$REPO_NAME")"
64
+ REPO_BASE=$(get_repo_base_branch "$PROJECT_YAML" "$repo_url")
65
+
66
+ if [[ ! -e "$REPO_DIR/.git" ]]; then
67
+ warn "Repo $REPO_NAME not cloned locally — skipping sync."
68
+ continue
69
+ fi
70
+
71
+ echo "Syncing $REPO_NAME: $REPO_BASE → $BRANCH..."
72
+ git -C "$REPO_DIR" fetch origin "$REPO_BASE"
73
+ git -C "$REPO_DIR" checkout "$BRANCH"
74
+ if ! git -C "$REPO_DIR" merge --no-edit "origin/$REPO_BASE" 2>/dev/null; then
75
+ echo ""
76
+ echo "MERGE CONFLICT: $REPO_BASE → $BRANCH in $REPO_NAME."
77
+ echo "Resolve conflicts manually, commit, then re-run: bash sync.sh $PROJECT_ID"
78
+ exit 2
79
+ fi
80
+ git -C "$REPO_DIR" push origin "$BRANCH"
81
+ info "$REPO_NAME synced."
82
+ done < <(get_project_repos "$PROJECT_YAML")
83
+
84
+ echo ""
85
+ echo "=== Sync complete. All project branches are current."
86
+ echo ""
87
+ echo "[ C03 ] Reload knowledge layers before continuing work:"
88
+ echo " 1. $WORKSPACE_REPO/knowledge/"
89
+ echo " 2. $WORKSPACE_REPO/projects/$PROJECT_ID/knowledge/"
90
+ echo " 3. <repo>/knowledge/ for each repo"
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env bash
2
+ # Script: test-merge
3
+ # Purpose: Pre-merge gate for the workspace repo's default branch.
4
+ # Validates that merging <source-branch> will not violate schema
5
+ # or invariants. On pass, fast-forwards local default to the
6
+ # merged tip — caller pushes. On fail, leaves local default
7
+ # unchanged.
8
+ #
9
+ # Usage: bash scripts/test-merge.sh <source-branch>
10
+ #
11
+ # Behavior:
12
+ # 1. Sync local $DEFAULT_BRANCH with remote (only legitimate remote read)
13
+ # 2. Create ephemeral test branch test-merge/<source> from $DEFAULT_BRANCH
14
+ # 3. Merge <source> into test branch (no push)
15
+ # 4. Run scripts/validate/run.py against the merged tree
16
+ # 5a. Pass: fast-forward $DEFAULT_BRANCH to test-merge tip; delete test branch
17
+ # 5b. Fail: discard test branch; $DEFAULT_BRANCH untouched; exit non-zero
18
+ #
19
+ # This script is the foundation of the test-merge gate strategy. See:
20
+ # knowledge/guidance/test-merge-gate.md (TODO)
21
+ #
22
+ # Compliance: C01 — workspace integrity gate
23
+
24
+ set -euo pipefail
25
+ source "$(dirname "$0")/lib.sh"
26
+ load_config
27
+
28
+ SOURCE_BRANCH="${1:-}"
29
+ [[ -n "$SOURCE_BRANCH" ]] || hard_stop "Usage: $0 <source-branch>"
30
+
31
+ cd "$REPO_ROOT"
32
+
33
+ ORIGINAL_BRANCH=$(git rev-parse --abbrev-ref HEAD)
34
+ TEST_BRANCH="test-merge/$SOURCE_BRANCH"
35
+ VALIDATOR="$REPO_ROOT/scripts/validate/run.py"
36
+
37
+ [[ -x "$VALIDATOR" ]] || hard_stop "Validator not found or not executable: $VALIDATOR"
38
+
39
+ # Verify source branch exists locally or on remote
40
+ if ! git rev-parse --verify "$SOURCE_BRANCH" &>/dev/null; then
41
+ if git ls-remote --exit-code origin "$SOURCE_BRANCH" &>/dev/null; then
42
+ info "Fetching $SOURCE_BRANCH from origin..."
43
+ git fetch origin "$SOURCE_BRANCH:$SOURCE_BRANCH" 2>/dev/null \
44
+ || git fetch origin "$SOURCE_BRANCH"
45
+ else
46
+ hard_stop "Source branch '$SOURCE_BRANCH' not found locally or on remote."
47
+ fi
48
+ fi
49
+
50
+ # Cleanup helper — used on failure to restore state
51
+ on_fail() {
52
+ git merge --abort 2>/dev/null || true
53
+ git checkout "$ORIGINAL_BRANCH" 2>/dev/null \
54
+ || git checkout "$DEFAULT_BRANCH" 2>/dev/null || true
55
+ git branch -D "$TEST_BRANCH" 2>/dev/null || true
56
+ }
57
+
58
+ # 1. Sync local default with remote
59
+ info "Syncing local $DEFAULT_BRANCH with origin..."
60
+ git fetch origin "$DEFAULT_BRANCH"
61
+ git checkout "$DEFAULT_BRANCH"
62
+ if ! git pull --ff-only origin "$DEFAULT_BRANCH"; then
63
+ on_fail
64
+ hard_stop "Cannot fast-forward local $DEFAULT_BRANCH from remote — resolve manually."
65
+ fi
66
+
67
+ # 2. Create ephemeral test branch from current default tip
68
+ if git rev-parse --verify "$TEST_BRANCH" &>/dev/null; then
69
+ warn "Stale test branch '$TEST_BRANCH' exists from a previous run — deleting."
70
+ git branch -D "$TEST_BRANCH"
71
+ fi
72
+ git checkout -b "$TEST_BRANCH"
73
+
74
+ # 3. Merge source into test branch (local only)
75
+ info "Test-merging '$SOURCE_BRANCH' into '$TEST_BRANCH'..."
76
+ if ! git merge --no-ff -m "test-merge: $SOURCE_BRANCH" "$SOURCE_BRANCH"; then
77
+ on_fail
78
+ hard_stop "Merge conflict: '$SOURCE_BRANCH' cannot merge cleanly into '$DEFAULT_BRANCH'. Resolve before retrying."
79
+ fi
80
+
81
+ # 4. Run validators against merged tree
82
+ echo ""
83
+ info "Running validators against merged tree..."
84
+ echo ""
85
+ if ! python3 "$VALIDATOR" "$REPO_ROOT"; then
86
+ echo ""
87
+ on_fail
88
+ hard_stop "Test-merge gate FAILED for '$SOURCE_BRANCH'. Local '$DEFAULT_BRANCH' is unchanged."
89
+ fi
90
+
91
+ # 5. Pass: fast-forward default to test-merge tip; clean up test branch
92
+ echo ""
93
+ info "Test-merge gate PASSED. Promoting to local '$DEFAULT_BRANCH'..."
94
+ git checkout "$DEFAULT_BRANCH"
95
+ git merge --ff-only "$TEST_BRANCH"
96
+ git branch -d "$TEST_BRANCH"
97
+
98
+ echo ""
99
+ info "✓ Local '$DEFAULT_BRANCH' now contains the merge of '$SOURCE_BRANCH'."
100
+ info " Caller should: git push origin $DEFAULT_BRANCH"
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Knowledge Organization Standard enforcement (POL-416).
4
+
5
+ Checks every *.md under knowledge/ (org tree only — framework/ is the
6
+ upstream template and excluded):
7
+
8
+ 1. front-matter — present, schema-valid, domain/layer agree with the
9
+ file's folder (domain-root instruments are exempt from layer-folder
10
+ agreement; their paths are pinned by policy text — see the C03
11
+ deviation in the PRJ-005 migration).
12
+ 2. orphan check — every non-README doc is linked from at least one other
13
+ knowledge doc (its layer index or a journey doc).
14
+ 3. journey purity — paths/*.md are links-in-order docs: no code blocks,
15
+ no embedded images, and a minimum link density.
16
+ 4. link check — every relative md link resolves; no [[wikilinks]].
17
+ 5. diagram rule — no binary diagram embeds (png/jpg/gif) in knowledge/;
18
+ diagrams are Mermaid text (POL-414). Files whose link path contains
19
+ "screenshot" are exempt.
20
+
21
+ Superseded redirect stubs (status: superseded) are exempt from orphan and
22
+ folder-agreement checks but must still parse.
23
+ """
24
+
25
+ import re
26
+ from pathlib import Path
27
+
28
+ DOMAINS = {
29
+ "policies", "legal", "architecture/system", "architecture/data",
30
+ "development", "testing", "deployment", "infrastructure", "support",
31
+ "compliance", "navigation",
32
+ }
33
+ LAYERS = {"mandate", "procedure", "pattern", "use-case", "spec", "compliance", "path"}
34
+ COMPLIANCE = {"C01", "C02", "C03", "instructional", "descriptive", "evidence"}
35
+ STATUSES = {"current", "draft", "superseded"}
36
+ LAYER_FOLDER = {
37
+ "mandates": "mandate", "procedures": "procedure", "patterns": "pattern",
38
+ "use-cases": "use-case", "specs": "spec", "compliance": "compliance",
39
+ "paths": "path",
40
+ }
41
+ FM_RE = re.compile(r"\A---\n(.*?)\n---\n", re.S)
42
+ LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")
43
+ WIKILINK_RE = re.compile(r"\[\[[^\]]+\]\]")
44
+ IMG_RE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)\)")
45
+
46
+
47
+ def _front_matter(text: str) -> dict | None:
48
+ m = FM_RE.match(text)
49
+ if not m:
50
+ return None
51
+ fm = {}
52
+ for line in m.group(1).splitlines():
53
+ if ":" in line and not line.lstrip().startswith("#"):
54
+ k, _, v = line.partition(":")
55
+ fm[k.strip()] = v.strip()
56
+ return fm
57
+
58
+
59
+ def check_knowledge(repo_root: Path) -> list[str]:
60
+ errors: list[str] = []
61
+ kroot = repo_root / "knowledge"
62
+ if not kroot.is_dir():
63
+ return ["knowledge/ directory missing"]
64
+
65
+ docs: dict[Path, str] = {
66
+ p: p.read_text(encoding="utf-8", errors="replace")
67
+ for p in sorted(kroot.rglob("*.md"))
68
+ }
69
+ linked: set[Path] = set()
70
+
71
+ def _strip_code(t: str) -> str:
72
+ # CommonMark-ish fence matching: a fence opened with N backticks only
73
+ # closes on a line with >= N backticks (so ````markdown wrappers can
74
+ # embed ``` blocks). Then inline spans; then indented code (4+ spaces).
75
+ out: list[str] = []
76
+ fence_len = 0 # 0 = not in a fence
77
+ for l in t.splitlines():
78
+ stripped = l.lstrip()
79
+ m = re.match(r"^(`{3,})", stripped)
80
+ if m:
81
+ n = len(m.group(1))
82
+ if fence_len == 0:
83
+ fence_len = n # open
84
+ elif n >= fence_len:
85
+ fence_len = 0 # close
86
+ continue
87
+ if fence_len == 0:
88
+ out.append(l)
89
+ t = re.sub(r"`[^`\n]*`", "", "\n".join(out))
90
+ return "\n".join(l for l in t.splitlines() if not l.startswith(" "))
91
+
92
+ # Pass 1 — links, wikilinks, images, and link-graph construction
93
+ for p, text in docs.items():
94
+ rel = p.relative_to(repo_root)
95
+ if WIKILINK_RE.search(_strip_code(text)):
96
+ errors.append(f"{rel}: [[wikilink]] found — use relative markdown links (POL-413)")
97
+ for target in IMG_RE.findall(text):
98
+ if target.lower().endswith((".png", ".jpg", ".jpeg", ".gif")) and "screenshot" not in target.lower():
99
+ errors.append(f"{rel}: binary diagram embed '{target}' — diagrams are Mermaid text (POL-414)")
100
+ for target in LINK_RE.findall(text):
101
+ if target.startswith(("http://", "https://", "mailto:", "#")):
102
+ continue
103
+ tpath = (p.parent / target.split("#")[0]).resolve()
104
+ if not tpath.exists():
105
+ errors.append(f"{rel}: broken link '{target}'")
106
+ else:
107
+ try:
108
+ linked.add(tpath.relative_to(repo_root.resolve()))
109
+ except ValueError:
110
+ pass
111
+
112
+ # Pass 2 — front-matter, folder agreement, orphan, journey purity
113
+ for p, text in docs.items():
114
+ rel = p.relative_to(repo_root)
115
+ fm = _front_matter(text)
116
+ if fm is None:
117
+ errors.append(f"{rel}: missing front-matter (POL-408)")
118
+ continue
119
+ for key, allowed in (("domain", DOMAINS), ("layer", LAYERS),
120
+ ("compliance", COMPLIANCE), ("status", STATUSES)):
121
+ val = fm.get(key)
122
+ if val not in allowed:
123
+ errors.append(f"{rel}: front-matter {key}='{val}' invalid (POL-408)")
124
+ if not fm.get("owner"):
125
+ errors.append(f"{rel}: front-matter owner missing (POL-408)")
126
+
127
+ if fm.get("status") == "superseded":
128
+ continue # redirect stubs: parse-only
129
+
130
+ parts = rel.parts # ('knowledge', <domain..>, [layer], file)
131
+ folder_layer = None
132
+ for seg in parts[1:-1]:
133
+ if seg in LAYER_FOLDER:
134
+ folder_layer = LAYER_FOLDER[seg]
135
+ if folder_layer and fm.get("layer") != folder_layer:
136
+ errors.append(
137
+ f"{rel}: layer '{fm.get('layer')}' disagrees with folder '{folder_layer}' (POL-408)"
138
+ )
139
+
140
+ # Orphan check: non-README docs must be linked from somewhere.
141
+ if p.name != "README.md":
142
+ try:
143
+ relresolved = p.resolve().relative_to(repo_root.resolve())
144
+ except ValueError:
145
+ relresolved = rel
146
+ if relresolved not in linked:
147
+ errors.append(f"{rel}: orphan — not linked from any index or journey (POL-416)")
148
+
149
+ # Journey purity
150
+ if parts[1] == "paths" and p.name != "README.md":
151
+ if "```" in text:
152
+ errors.append(f"{rel}: journey docs are links-only — code block found (POL-410)")
153
+ if IMG_RE.search(text):
154
+ errors.append(f"{rel}: journey docs are links-only — image found (POL-410)")
155
+ if len(LINK_RE.findall(text)) < 3:
156
+ errors.append(f"{rel}: journey doc has fewer than 3 links — is it a journey? (POL-410)")
157
+
158
+ return errors