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