Validate shared instructions, skill discovery copies, and local-settings hygiene.
121def audit() -> list[str]:
122 """!
123 @brief Validate shared instructions, skill discovery copies, and local-settings hygiene.
124 @return Human-readable violations; an empty list means the audit passed.
125 """
126
127 errors: list[str] = []
128 agents = REPO_ROOT / "AGENTS.md"
129 claude = REPO_ROOT / "CLAUDE.md"
130
131 if not agents.is_file() or agents.is_symlink():
132 errors.append("AGENTS.md must be a tracked regular file, not a symlink")
133 if not claude.is_file() or claude.is_symlink():
134 errors.append("CLAUDE.md must be a regular file, not a symlink")
135 elif "@AGENTS.md" not in {line.strip() for line in claude.read_text(encoding="utf-8").splitlines()}:
136 errors.append("CLAUDE.md must import the canonical @AGENTS.md instructions")
137
138 canonical = skill_entries(CANONICAL_ROOT)
139 copies = skill_entries(CLAUDE_ROOT)
140 canonical_names = entry_names(CANONICAL_ROOT)
141 copy_names = entry_names(CLAUDE_ROOT)
142 if canonical_names != EXPECTED_SKILLS:
143 errors.append(
144 "canonical skill set mismatch: "
145 f"missing={sorted(EXPECTED_SKILLS - canonical_names)}, "
146 f"extra={sorted(canonical_names - EXPECTED_SKILLS)}"
147 )
148 if copy_names != EXPECTED_SKILLS:
149 errors.append(
150 "Claude skill set mismatch: "
151 f"missing={sorted(EXPECTED_SKILLS - copy_names)}, "
152 f"extra={sorted(copy_names - EXPECTED_SKILLS)}; run make sync-agent-skills"
153 )
154
155 for name in sorted(EXPECTED_SKILLS & set(canonical)):
156 source_dir = canonical[name]
157 source = source_dir / "SKILL.md"
158 if source_dir.is_symlink() or not source.is_file() or source.is_symlink():
159 errors.append(f"canonical skill {name} must contain a regular SKILL.md")
160 continue
161 metadata = frontmatter(source)
162 if metadata.get("name") != name:
163 errors.append(f"{source.relative_to(REPO_ROOT)} front-matter name must be {name!r}")
164 if not metadata.get("description"):
165 errors.append(f"{source.relative_to(REPO_ROOT)} requires a non-empty description")
166
167 target_dir = copies.get(name)
168 target = target_dir / "SKILL.md" if target_dir else None
169 if target_dir is None:
170 continue
171 if target_dir.is_symlink() or target is None or not target.is_file() or target.is_symlink():
172 errors.append(f"Claude skill {name} must be a materialized regular-file copy")
173 continue
174 source_files = sorted(
175 path.relative_to(source_dir) for path in source_dir.rglob("*") if path.is_file()
176 )
177 target_files = sorted(
178 path.relative_to(target_dir) for path in target_dir.rglob("*") if path.is_file()
179 )
180 if source_files != target_files:
181 errors.append(f"Claude skill {name} file set differs; run make sync-agent-skills")
182 continue
183 for relative in source_files:
184 if (source_dir / relative).read_bytes() != (target_dir / relative).read_bytes():
185 errors.append(
186 f"Claude skill {name}/{relative} differs byte-for-byte; "
187 "run make sync-agent-skills"
188 )
189
190 tracked = git("ls-files", "--error-unmatch", "--", LOCAL_SETTINGS)
191 if tracked.returncode == 0:
192 errors.append(f"{LOCAL_SETTINGS} is machine-local and must not be tracked")
193 ignored = git("check-ignore", "-v", "--", LOCAL_SETTINGS)
194 if ignored.returncode != 0:
195 errors.append(f"{LOCAL_SETTINGS} must be ignored by the repository .gitignore")
196 else:
197 fields = ignored.stdout.rstrip().split("\t", 1)[0].rsplit(":", 2)
198 source = fields[0] if len(fields) == 3 else ""
199 pattern = fields[2] if len(fields) == 3 else ""
200 if Path(source).name != ".gitignore" or pattern != LOCAL_SETTINGS_PATTERN:
201 errors.append(
202 f"{LOCAL_SETTINGS} must use the exact repository ignore pattern "
203 f"{LOCAL_SETTINGS_PATTERN!r}, not a user-global or incidental rule"
204 )
205
206 return errors
207
208