anidb-client 1.0.0__tar.gz
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.
- anidb_client-1.0.0/.claude/agents/scripts/ci-drift-detector.mjs +195 -0
- anidb_client-1.0.0/.claude/skills/adr/scripts/generate_index.py +154 -0
- anidb_client-1.0.0/.claude/skills/spec/scripts/generate_index.py +145 -0
- anidb_client-1.0.0/.gitignore +67 -0
- anidb_client-1.0.0/LICENSE +675 -0
- anidb_client-1.0.0/PKG-INFO +512 -0
- anidb_client-1.0.0/README.md +488 -0
- anidb_client-1.0.0/pyproject.toml +207 -0
- anidb_client-1.0.0/scripts/__init__.py +6 -0
- anidb_client-1.0.0/scripts/conventional_commit.py +196 -0
- anidb_client-1.0.0/scripts/release_tag.py +147 -0
- anidb_client-1.0.0/src/anidb_client/ISO-639-2_utf-8.txt +486 -0
- anidb_client-1.0.0/src/anidb_client/__init__.py +287 -0
- anidb_client-1.0.0/src/anidb_client/anames.py +466 -0
- anidb_client-1.0.0/src/anidb_client/animeobjs.py +2035 -0
- anidb_client-1.0.0/src/anidb_client/commands.py +573 -0
- anidb_client-1.0.0/src/anidb_client/db.py +523 -0
- anidb_client-1.0.0/src/anidb_client/errors.py +76 -0
- anidb_client-1.0.0/src/anidb_client/fileinfo.py +167 -0
- anidb_client-1.0.0/src/anidb_client/link.py +926 -0
- anidb_client-1.0.0/src/anidb_client/mapper.py +460 -0
- anidb_client-1.0.0/src/anidb_client/ratelimit.py +206 -0
- anidb_client-1.0.0/src/anidb_client/responses.py +2009 -0
- anidb_client-1.0.0/tests/__init__.py +6 -0
- anidb_client-1.0.0/tests/conftest.py +189 -0
- anidb_client-1.0.0/tests/factories.py +163 -0
- anidb_client-1.0.0/tests/fake_anidb.py +171 -0
- anidb_client-1.0.0/tests/integration/__init__.py +7 -0
- anidb_client-1.0.0/tests/integration/test_link.py +957 -0
- anidb_client-1.0.0/tests/integration/test_schema_postgres.py +246 -0
- anidb_client-1.0.0/tests/objectlayer.py +123 -0
- anidb_client-1.0.0/tests/schema_snapshot.py +135 -0
- anidb_client-1.0.0/tests/schema_snapshots/postgresql.sql +148 -0
- anidb_client-1.0.0/tests/schema_snapshots/sqlite.sql +138 -0
- anidb_client-1.0.0/tests/test_network_guard.py +76 -0
- anidb_client-1.0.0/tests/unit/__init__.py +1 -0
- anidb_client-1.0.0/tests/unit/test_attribute_resolution.py +116 -0
- anidb_client-1.0.0/tests/unit/test_cache_freshness.py +217 -0
- anidb_client-1.0.0/tests/unit/test_cache_session_lifecycle.py +113 -0
- anidb_client-1.0.0/tests/unit/test_commands.py +322 -0
- anidb_client-1.0.0/tests/unit/test_conventional_commit.py +213 -0
- anidb_client-1.0.0/tests/unit/test_db.py +475 -0
- anidb_client-1.0.0/tests/unit/test_enum_converters.py +335 -0
- anidb_client-1.0.0/tests/unit/test_episode_from_filename.py +154 -0
- anidb_client-1.0.0/tests/unit/test_external_mapping.py +252 -0
- anidb_client-1.0.0/tests/unit/test_fanart_http.py +167 -0
- anidb_client-1.0.0/tests/unit/test_file_identity.py +167 -0
- anidb_client-1.0.0/tests/unit/test_file_response_decoding.py +61 -0
- anidb_client-1.0.0/tests/unit/test_fileinfo.py +204 -0
- anidb_client-1.0.0/tests/unit/test_filename_inference.py +193 -0
- anidb_client-1.0.0/tests/unit/test_http_timeouts.py +136 -0
- anidb_client-1.0.0/tests/unit/test_init_credentials.py +143 -0
- anidb_client-1.0.0/tests/unit/test_init_database.py +85 -0
- anidb_client-1.0.0/tests/unit/test_mapper.py +135 -0
- anidb_client-1.0.0/tests/unit/test_mylist.py +407 -0
- anidb_client-1.0.0/tests/unit/test_notfound_paths.py +208 -0
- anidb_client-1.0.0/tests/unit/test_objectlayer_fixture.py +130 -0
- anidb_client-1.0.0/tests/unit/test_package.py +86 -0
- anidb_client-1.0.0/tests/unit/test_ratelimit.py +262 -0
- anidb_client-1.0.0/tests/unit/test_release_tag.py +152 -0
- anidb_client-1.0.0/tests/unit/test_response_field_selection.py +171 -0
- anidb_client-1.0.0/tests/unit/test_responses.py +292 -0
- anidb_client-1.0.0/tests/unit/test_schema_snapshot.py +54 -0
- anidb_client-1.0.0/tests/unit/test_sql_url_credentials.py +169 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CI invocation script for the drift detector agent.
|
|
5
|
+
*
|
|
6
|
+
* Determines the diff, passes it to Claude via the Agent SDK with the
|
|
7
|
+
* drift-detector agent instructions as system prompt, and exits with
|
|
8
|
+
* the appropriate code:
|
|
9
|
+
* 0 — no drift detected
|
|
10
|
+
* 1 — drift detected
|
|
11
|
+
* 2 — infrastructure error
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
18
|
+
|
|
19
|
+
// --- Structured output schema ---
|
|
20
|
+
|
|
21
|
+
const driftSchema = {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
drift_detected: { type: "boolean" },
|
|
25
|
+
findings: {
|
|
26
|
+
type: "array",
|
|
27
|
+
items: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
file: { type: "string" },
|
|
31
|
+
lines: { type: "string" },
|
|
32
|
+
affected_artifact: { type: "string" },
|
|
33
|
+
justification: { type: "string" },
|
|
34
|
+
suggestion: { type: "string" },
|
|
35
|
+
},
|
|
36
|
+
required: ["file", "affected_artifact", "justification", "suggestion"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
summary: { type: "string" },
|
|
40
|
+
},
|
|
41
|
+
required: ["drift_detected", "findings", "summary"],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// --- Helpers ---
|
|
45
|
+
|
|
46
|
+
function git(cmd) {
|
|
47
|
+
return execSync(`git ${cmd}`, { encoding: "utf-8" }).trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseArgs(argv) {
|
|
51
|
+
const args = { base: null, head: null };
|
|
52
|
+
for (let i = 2; i < argv.length; i++) {
|
|
53
|
+
if (argv[i] === "--base" && argv[i + 1]) {
|
|
54
|
+
args.base = argv[++i];
|
|
55
|
+
} else if (argv[i] === "--head" && argv[i + 1]) {
|
|
56
|
+
args.head = argv[++i];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return args;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function determineDiff(cliArgs) {
|
|
63
|
+
// 1. Explicit CLI refs
|
|
64
|
+
if (cliArgs.base && cliArgs.head) {
|
|
65
|
+
return git(`diff ${cliArgs.base}..${cliArgs.head}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. GitLab MR pipeline — diff the MR base against current commit
|
|
69
|
+
const mrBase = process.env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
|
|
70
|
+
const commitSha = process.env.CI_COMMIT_SHA;
|
|
71
|
+
if (mrBase && commitSha) {
|
|
72
|
+
return git(`diff ${mrBase}..${commitSha}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. Branch pipeline — diff against merge base with default branch
|
|
76
|
+
const branch = process.env.CI_COMMIT_BRANCH;
|
|
77
|
+
const defaultBranch = process.env.CI_DEFAULT_BRANCH;
|
|
78
|
+
if (branch && defaultBranch) {
|
|
79
|
+
if (branch === defaultBranch) {
|
|
80
|
+
// On main: diff the latest commit only
|
|
81
|
+
return git("diff HEAD~1..HEAD");
|
|
82
|
+
}
|
|
83
|
+
// Feature branch: deepen history and fetch default branch to find merge base
|
|
84
|
+
try { git("fetch --unshallow"); } catch {}
|
|
85
|
+
git(`fetch origin ${defaultBranch}`);
|
|
86
|
+
const mergeBase = git(`merge-base origin/${defaultBranch} HEAD`);
|
|
87
|
+
return git(`diff ${mergeBase}..HEAD`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. Cannot determine — fail explicitly
|
|
91
|
+
console.error(
|
|
92
|
+
"Error: Cannot determine diff. Provide --base and --head, " +
|
|
93
|
+
"or run in a GitLab MR/branch pipeline.",
|
|
94
|
+
);
|
|
95
|
+
process.exit(2);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function formatFindings(result) {
|
|
99
|
+
if (!result.drift_detected || result.findings.length === 0) {
|
|
100
|
+
console.log("No drift detected.");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const f of result.findings) {
|
|
105
|
+
const location = f.lines ? `${f.file}:${f.lines}` : f.file;
|
|
106
|
+
console.log(`DRIFT: ${location}`);
|
|
107
|
+
console.log(` Affected: ${f.affected_artifact}`);
|
|
108
|
+
console.log(` Reason: ${f.justification}`);
|
|
109
|
+
console.log(` Suggestion: ${f.suggestion}`);
|
|
110
|
+
console.log();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (result.summary) {
|
|
114
|
+
console.log(result.summary);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Main ---
|
|
119
|
+
|
|
120
|
+
async function main() {
|
|
121
|
+
const cliArgs = parseArgs(process.argv);
|
|
122
|
+
const diff = determineDiff(cliArgs);
|
|
123
|
+
|
|
124
|
+
if (!diff) {
|
|
125
|
+
console.log("Empty diff — nothing to check.");
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Read the agent instructions from the markdown file (strip frontmatter)
|
|
130
|
+
const agentPath = join(process.cwd(), ".claude", "agents", "drift-detector.md");
|
|
131
|
+
const agentRaw = readFileSync(agentPath, "utf-8");
|
|
132
|
+
const agentPrompt = agentRaw.replace(/^---[\s\S]*?---\n*/, "");
|
|
133
|
+
|
|
134
|
+
const prompt = [
|
|
135
|
+
"Analyze the following diff for drift against the project's specs, ADRs, tests, and navigation aids.",
|
|
136
|
+
"",
|
|
137
|
+
"```diff",
|
|
138
|
+
diff,
|
|
139
|
+
"```",
|
|
140
|
+
].join("\n");
|
|
141
|
+
|
|
142
|
+
let structured = null;
|
|
143
|
+
let resultMessage = null;
|
|
144
|
+
|
|
145
|
+
for await (const message of query({
|
|
146
|
+
prompt,
|
|
147
|
+
options: {
|
|
148
|
+
systemPrompt: agentPrompt,
|
|
149
|
+
allowedTools: ["Read", "Glob", "Grep", "Bash"],
|
|
150
|
+
permissionMode: "acceptEdits",
|
|
151
|
+
settingSources: ["project"],
|
|
152
|
+
cwd: process.cwd(),
|
|
153
|
+
outputFormat: {
|
|
154
|
+
type: "json_schema",
|
|
155
|
+
schema: driftSchema,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
})) {
|
|
159
|
+
if (message.type === "result") {
|
|
160
|
+
resultMessage = message;
|
|
161
|
+
structured = message.structured_output;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!resultMessage) {
|
|
166
|
+
console.error("Error: No result received from drift detector.");
|
|
167
|
+
process.exit(2);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Try structured_output first, fall back to parsing result text
|
|
171
|
+
if (!structured && resultMessage.result) {
|
|
172
|
+
try {
|
|
173
|
+
structured = JSON.parse(resultMessage.result);
|
|
174
|
+
} catch {
|
|
175
|
+
console.error("Error: Failed to parse result as JSON.");
|
|
176
|
+
console.error(resultMessage.result);
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!structured) {
|
|
182
|
+
console.error("Error: No output from drift detector.");
|
|
183
|
+
process.exit(2);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
formatFindings(structured);
|
|
187
|
+
process.exit(structured.drift_detected ? 1 : 0);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
main().catch((err) => {
|
|
191
|
+
console.error("Error: Drift detection failed.");
|
|
192
|
+
console.error(err.message || err);
|
|
193
|
+
if (err.stack) console.error(err.stack);
|
|
194
|
+
process.exit(2);
|
|
195
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate ADR frontmatter and generate docs/decisions/INDEX.md."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
DECISIONS_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent / "docs" / "decisions"
|
|
11
|
+
INDEX_PATH = DECISIONS_DIR / "INDEX.md"
|
|
12
|
+
FILENAME_RE = re.compile(r"^ADR-(\d{3})-[\w-]+\.md$")
|
|
13
|
+
VALID_STATUSES = {"draft", "accepted"}
|
|
14
|
+
REQUIRED_FIELDS = ("title", "description", "status", "tags")
|
|
15
|
+
REQUIRED_SECTIONS = ("Context", "Decision", "Consequences")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_frontmatter(path: Path) -> dict | None:
|
|
19
|
+
"""Extract YAML frontmatter from a file. Returns None if no frontmatter."""
|
|
20
|
+
text = path.read_text(encoding="utf-8")
|
|
21
|
+
if not text.startswith("---"):
|
|
22
|
+
return None
|
|
23
|
+
end = text.find("---", 3)
|
|
24
|
+
if end == -1:
|
|
25
|
+
return None
|
|
26
|
+
raw = text[3:end]
|
|
27
|
+
return yaml.safe_load(raw)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_adrs(adr_files: list[Path]) -> tuple[list[dict], list[str], list[str]]:
|
|
31
|
+
"""Validate all ADR files. Returns (parsed entries, errors, warnings)."""
|
|
32
|
+
errors: list[str] = []
|
|
33
|
+
warnings: list[str] = []
|
|
34
|
+
entries: list[dict] = []
|
|
35
|
+
|
|
36
|
+
for path in adr_files:
|
|
37
|
+
name = path.name
|
|
38
|
+
match = FILENAME_RE.match(name)
|
|
39
|
+
if not match:
|
|
40
|
+
errors.append(f"ERROR [{name}]: Filename doesn't match ADR-NNN-slug.md pattern")
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
number = int(match.group(1))
|
|
44
|
+
fm = parse_frontmatter(path)
|
|
45
|
+
|
|
46
|
+
if fm is None:
|
|
47
|
+
errors.append(f"ERROR [{name}]: Missing YAML frontmatter entirely")
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
file_errors = False
|
|
51
|
+
for field in REQUIRED_FIELDS:
|
|
52
|
+
if not fm.get(field):
|
|
53
|
+
errors.append(f"ERROR [{name}]: Missing required field: {field}")
|
|
54
|
+
file_errors = True
|
|
55
|
+
|
|
56
|
+
status = fm.get("status", "")
|
|
57
|
+
if status == "draft":
|
|
58
|
+
errors.append(f"ERROR [{name}]: status is 'draft' (signals incomplete work)")
|
|
59
|
+
file_errors = True
|
|
60
|
+
elif status and status not in VALID_STATUSES:
|
|
61
|
+
errors.append(f"ERROR [{name}]: Unrecognized status '{status}' (expected 'draft' or 'accepted')")
|
|
62
|
+
file_errors = True
|
|
63
|
+
|
|
64
|
+
# Check required body sections
|
|
65
|
+
text = path.read_text(encoding="utf-8")
|
|
66
|
+
body = text.split("---", 2)[-1] if text.startswith("---") else text
|
|
67
|
+
for section in REQUIRED_SECTIONS:
|
|
68
|
+
if not re.search(rf"^#{{1,6}}\s+{section}\b", body, re.MULTILINE):
|
|
69
|
+
errors.append(f"ERROR [{name}]: Missing required section: {section}")
|
|
70
|
+
file_errors = True
|
|
71
|
+
|
|
72
|
+
if not file_errors:
|
|
73
|
+
entries.append(
|
|
74
|
+
{
|
|
75
|
+
"number": number,
|
|
76
|
+
"filename": name,
|
|
77
|
+
"title": fm["title"],
|
|
78
|
+
"description": fm["description"],
|
|
79
|
+
"status": fm["status"],
|
|
80
|
+
"tags": ", ".join(fm.get("tags", [])),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return entries, errors, warnings
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def generate_index(entries: list[dict]) -> str:
|
|
88
|
+
"""Generate INDEX.md content from validated entries."""
|
|
89
|
+
entries.sort(key=lambda e: e["number"])
|
|
90
|
+
|
|
91
|
+
lines = [
|
|
92
|
+
"<!-- AUTO-GENERATED by .claude/skills/adr/scripts/generate_index.py — do not edit manually -->",
|
|
93
|
+
"",
|
|
94
|
+
"# Architecture Decision Records",
|
|
95
|
+
"",
|
|
96
|
+
"| Number | Title | Description | Status | Tags |",
|
|
97
|
+
"|--------|-------|-------------|--------|------|",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
for e in entries:
|
|
101
|
+
num = f"ADR-{e['number']:03d}"
|
|
102
|
+
link = f"[{num}]({e['filename']})"
|
|
103
|
+
lines.append(f"| {link} | {e['title']} | {e['description']} | {e['status']} | {e['tags']} |")
|
|
104
|
+
|
|
105
|
+
lines.append("")
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> int:
|
|
110
|
+
if not DECISIONS_DIR.is_dir():
|
|
111
|
+
print(f"ERROR: Directory not found: {DECISIONS_DIR}", file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
adr_files = sorted(DECISIONS_DIR.glob("ADR-*.md"))
|
|
115
|
+
if not adr_files:
|
|
116
|
+
print("No ADR files found.", file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
entries, errors, warnings = validate_adrs(adr_files)
|
|
120
|
+
|
|
121
|
+
for w in warnings:
|
|
122
|
+
print(w, file=sys.stderr)
|
|
123
|
+
for e in errors:
|
|
124
|
+
print(e, file=sys.stderr)
|
|
125
|
+
|
|
126
|
+
if errors:
|
|
127
|
+
print(f"\n{len(errors)} error(s) found. Fix them before INDEX.md can be generated.", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
|
|
130
|
+
index_content = generate_index(entries)
|
|
131
|
+
|
|
132
|
+
# --check verifies the committed index without writing, for CI. Done here rather
|
|
133
|
+
# than by regenerating and diffing with git, because the image this runs in is a
|
|
134
|
+
# Python toolchain and carries no git -- and adding one to compare two strings
|
|
135
|
+
# would be an unpinned apt package in the supply chain for no gain.
|
|
136
|
+
if "--check" in sys.argv[1:]:
|
|
137
|
+
committed = INDEX_PATH.read_text(encoding="utf-8") if INDEX_PATH.is_file() else None
|
|
138
|
+
if committed != index_content:
|
|
139
|
+
print(
|
|
140
|
+
f"ERROR: {INDEX_PATH} is out of date with the ADR frontmatter.\n"
|
|
141
|
+
f"Run `task adr-index` and commit the result.",
|
|
142
|
+
file=sys.stderr,
|
|
143
|
+
)
|
|
144
|
+
return 1
|
|
145
|
+
print(f"{INDEX_PATH} is up to date ({len(entries)} entries).")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
INDEX_PATH.write_text(index_content, encoding="utf-8")
|
|
149
|
+
print(f"Generated {INDEX_PATH} with {len(entries)} entries.")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
sys.exit(main())
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate spec frontmatter and generate docs/specs/INDEX.md."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
SPECS_DIR = Path(__file__).resolve().parent.parent.parent.parent.parent / "docs" / "specs"
|
|
11
|
+
INDEX_PATH = SPECS_DIR / "INDEX.md"
|
|
12
|
+
FILENAME_RE = re.compile(r"^SPEC-(\d{3})-[\w-]+\.md$")
|
|
13
|
+
VALID_STATUSES = {"draft", "accepted"}
|
|
14
|
+
REQUIRED_FIELDS = ("title", "description", "status", "tags")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_frontmatter(path: Path) -> dict | None:
|
|
18
|
+
"""Extract YAML frontmatter from a file. Returns None if no frontmatter."""
|
|
19
|
+
text = path.read_text(encoding="utf-8")
|
|
20
|
+
if not text.startswith("---"):
|
|
21
|
+
return None
|
|
22
|
+
end = text.find("---", 3)
|
|
23
|
+
if end == -1:
|
|
24
|
+
return None
|
|
25
|
+
raw = text[3:end]
|
|
26
|
+
return yaml.safe_load(raw)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_specs(spec_files: list[Path]) -> tuple[list[dict], list[str], list[str]]:
|
|
30
|
+
"""Validate all spec files. Returns (parsed entries, errors, warnings)."""
|
|
31
|
+
errors: list[str] = []
|
|
32
|
+
warnings: list[str] = []
|
|
33
|
+
entries: list[dict] = []
|
|
34
|
+
|
|
35
|
+
for path in spec_files:
|
|
36
|
+
name = path.name
|
|
37
|
+
match = FILENAME_RE.match(name)
|
|
38
|
+
if not match:
|
|
39
|
+
errors.append(f"ERROR [{name}]: Filename doesn't match SPEC-NNN-slug.md pattern")
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
number = int(match.group(1))
|
|
43
|
+
fm = parse_frontmatter(path)
|
|
44
|
+
|
|
45
|
+
if fm is None:
|
|
46
|
+
errors.append(f"ERROR [{name}]: Missing YAML frontmatter entirely")
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
file_errors = False
|
|
50
|
+
for field in REQUIRED_FIELDS:
|
|
51
|
+
if not fm.get(field):
|
|
52
|
+
errors.append(f"ERROR [{name}]: Missing required field: {field}")
|
|
53
|
+
file_errors = True
|
|
54
|
+
|
|
55
|
+
status = fm.get("status", "")
|
|
56
|
+
if status == "draft":
|
|
57
|
+
errors.append(f"ERROR [{name}]: status is 'draft' (signals incomplete work)")
|
|
58
|
+
file_errors = True
|
|
59
|
+
elif status and status not in VALID_STATUSES:
|
|
60
|
+
errors.append(f"ERROR [{name}]: Unrecognized status '{status}' (expected 'draft' or 'accepted')")
|
|
61
|
+
file_errors = True
|
|
62
|
+
|
|
63
|
+
if not file_errors:
|
|
64
|
+
entries.append(
|
|
65
|
+
{
|
|
66
|
+
"number": number,
|
|
67
|
+
"filename": name,
|
|
68
|
+
"title": fm["title"],
|
|
69
|
+
"description": fm["description"],
|
|
70
|
+
"status": fm["status"],
|
|
71
|
+
"tags": ", ".join(fm.get("tags", [])),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return entries, errors, warnings
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def generate_index(entries: list[dict]) -> str:
|
|
79
|
+
"""Generate INDEX.md content from validated entries."""
|
|
80
|
+
entries.sort(key=lambda e: e["number"])
|
|
81
|
+
|
|
82
|
+
lines = [
|
|
83
|
+
"<!-- AUTO-GENERATED by .claude/skills/spec/scripts/generate_index.py — do not edit manually -->",
|
|
84
|
+
"",
|
|
85
|
+
"# Specifications",
|
|
86
|
+
"",
|
|
87
|
+
"| Number | Title | Description | Status | Tags |",
|
|
88
|
+
"|--------|-------|-------------|--------|------|",
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
for e in entries:
|
|
92
|
+
num = f"SPEC-{e['number']:03d}"
|
|
93
|
+
link = f"[{num}]({e['filename']})"
|
|
94
|
+
lines.append(f"| {link} | {e['title']} | {e['description']} | {e['status']} | {e['tags']} |")
|
|
95
|
+
|
|
96
|
+
lines.append("")
|
|
97
|
+
return "\n".join(lines)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> int:
|
|
101
|
+
if not SPECS_DIR.is_dir():
|
|
102
|
+
print(f"ERROR: Directory not found: {SPECS_DIR}", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
spec_files = sorted(SPECS_DIR.glob("SPEC-*.md"))
|
|
106
|
+
if not spec_files:
|
|
107
|
+
print("No spec files found.", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
entries, errors, warnings = validate_specs(spec_files)
|
|
111
|
+
|
|
112
|
+
for w in warnings:
|
|
113
|
+
print(w, file=sys.stderr)
|
|
114
|
+
for e in errors:
|
|
115
|
+
print(e, file=sys.stderr)
|
|
116
|
+
|
|
117
|
+
if errors:
|
|
118
|
+
print(f"\n{len(errors)} error(s) found. Fix them before INDEX.md can be generated.", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
index_content = generate_index(entries)
|
|
122
|
+
|
|
123
|
+
# --check verifies the committed index without writing, for CI. Done here rather
|
|
124
|
+
# than by regenerating and diffing with git, because the image this runs in is a
|
|
125
|
+
# Python toolchain and carries no git -- and adding one to compare two strings
|
|
126
|
+
# would be an unpinned apt package in the supply chain for no gain.
|
|
127
|
+
if "--check" in sys.argv[1:]:
|
|
128
|
+
committed = INDEX_PATH.read_text(encoding="utf-8") if INDEX_PATH.is_file() else None
|
|
129
|
+
if committed != index_content:
|
|
130
|
+
print(
|
|
131
|
+
f"ERROR: {INDEX_PATH} is out of date with the spec frontmatter.\n"
|
|
132
|
+
f"Run `task spec-index` and commit the result.",
|
|
133
|
+
file=sys.stderr,
|
|
134
|
+
)
|
|
135
|
+
return 1
|
|
136
|
+
print(f"{INDEX_PATH} is up to date ({len(entries)} entries).")
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
INDEX_PATH.write_text(index_content, encoding="utf-8")
|
|
140
|
+
print(f"Generated {INDEX_PATH} with {len(entries)} entries.")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
sys.exit(main())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.so
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
.Python
|
|
8
|
+
build/
|
|
9
|
+
develop-eggs/
|
|
10
|
+
dist/
|
|
11
|
+
downloads/
|
|
12
|
+
eggs/
|
|
13
|
+
.eggs/
|
|
14
|
+
parts/
|
|
15
|
+
sdist/
|
|
16
|
+
var/
|
|
17
|
+
wheels/
|
|
18
|
+
*.egg-info/
|
|
19
|
+
.installed.cfg
|
|
20
|
+
*.egg
|
|
21
|
+
|
|
22
|
+
# Virtualenvs / environments
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
env/
|
|
26
|
+
ENV/
|
|
27
|
+
|
|
28
|
+
# uv
|
|
29
|
+
.uv-cache/
|
|
30
|
+
|
|
31
|
+
# Tooling caches
|
|
32
|
+
.ruff_cache/
|
|
33
|
+
.mypy_cache/
|
|
34
|
+
.pytest_cache/
|
|
35
|
+
.cache/
|
|
36
|
+
.tox/
|
|
37
|
+
|
|
38
|
+
# Test / coverage reports
|
|
39
|
+
htmlcov/
|
|
40
|
+
.coverage
|
|
41
|
+
.coverage.*
|
|
42
|
+
coverage.xml
|
|
43
|
+
report.xml
|
|
44
|
+
junit.xml
|
|
45
|
+
gl-code-quality-report.json
|
|
46
|
+
|
|
47
|
+
# Local cache written by the anime-titles / anime-list fetchers when a developer
|
|
48
|
+
# points them at the working tree instead of the system temp dir.
|
|
49
|
+
*.xml.gz
|
|
50
|
+
|
|
51
|
+
# Sphinx documentation
|
|
52
|
+
docs/_build/
|
|
53
|
+
|
|
54
|
+
# Secrets — never commit. The library reads credentials from a netrc file.
|
|
55
|
+
.netrc
|
|
56
|
+
*.netrc
|
|
57
|
+
|
|
58
|
+
# Local scratch databases
|
|
59
|
+
*.db
|
|
60
|
+
*.sqlite
|
|
61
|
+
*.sqlite3
|
|
62
|
+
|
|
63
|
+
# Editor / OS
|
|
64
|
+
.DS_Store
|
|
65
|
+
*.swp
|
|
66
|
+
.idea/
|
|
67
|
+
.vscode/
|