pi-matt-pocock 0.1.0 → 0.2.0

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,212 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile } from "node:child_process";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { promisify } from "node:util";
6
+ import { dirname, isAbsolute, resolve } from "node:path";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12
+ const selectionPath = resolve(packageDirectory, "upstream-selection.json");
13
+
14
+ function fail(message) {
15
+ throw new Error(message);
16
+ }
17
+
18
+ function requireString(value, field) {
19
+ if (typeof value !== "string" || value.trim() === "") {
20
+ fail(`${field} must be a non-empty string`);
21
+ }
22
+ }
23
+
24
+ function requireCommit(value, field) {
25
+ requireString(value, field);
26
+ if (!/^[0-9a-f]{40}$/.test(value)) {
27
+ fail(`${field} must be a full lowercase Git commit`);
28
+ }
29
+ }
30
+
31
+ async function requireFile(path, field) {
32
+ try {
33
+ if (!(await stat(path)).isFile()) fail(`${field} is not a file: ${path}`);
34
+ } catch {
35
+ fail(`${field} does not resolve: ${path}`);
36
+ }
37
+ }
38
+
39
+ function duplicates(values) {
40
+ return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))];
41
+ }
42
+
43
+ function splitSkill(markdown) {
44
+ const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
45
+ if (!match) fail("upstream SKILL.md has no YAML frontmatter");
46
+ const frontmatter = match[1];
47
+ const name = frontmatter.match(/^name:\s*["']?([^\r\n"']+)["']?\s*$/m)?.[1]?.trim();
48
+ const invocation = /^disable-model-invocation:\s*true\s*$/m.test(frontmatter) ? "user" : "model";
49
+ return { name, invocation, body: match[2].trim() };
50
+ }
51
+
52
+ function normalizedBody(value) {
53
+ return value.replace(/\r\n/g, "\n").trim();
54
+ }
55
+
56
+ function parseArguments(argv) {
57
+ if (argv.length === 0) return {};
58
+ if (argv.length === 2 && argv[0] === "--upstream") return { upstream: argv[1] };
59
+ fail("usage: node scripts/check-upstream-sync.mjs [--upstream <already-cloned-path>]");
60
+ }
61
+
62
+ async function validateCatalogMappings(metadata) {
63
+ const catalog = JSON.parse(await readFile(resolve(packageDirectory, "src/catalog.json"), "utf8"));
64
+ const definitions = new Map(catalog.map((definition) => [definition.id, definition]));
65
+ for (const [index, item] of metadata.selected.entries()) {
66
+ const definition = definitions.get(item.localCatalogId);
67
+ if (!definition) fail(`selected[${index}].localCatalogId is not in the procedure catalog`);
68
+ if (definition.file !== item.localResource.split("/").at(-1)) {
69
+ fail(`selected[${index}] catalog file does not match its local resource`);
70
+ }
71
+ if (definition.invocation !== item.upstreamInvocation) {
72
+ fail(`selected[${index}] catalog invocation is ${definition.invocation}, metadata says ${item.upstreamInvocation}`);
73
+ }
74
+ }
75
+ }
76
+
77
+ async function validateMetadata(metadata) {
78
+ if (metadata.schemaVersion !== 1) fail("schemaVersion must be 1");
79
+ requireString(metadata.repository, "repository");
80
+ requireCommit(metadata.comparedCommit, "comparedCommit");
81
+ requireString(metadata.comparisonTag, "comparisonTag");
82
+ requireCommit(metadata.resolvedTagCommit, "resolvedTagCommit");
83
+ requireCommit(metadata.latestCheckedCommit, "latestCheckedCommit");
84
+
85
+ const revisions = [
86
+ metadata.comparedCommit,
87
+ metadata.comparisonTag,
88
+ metadata.resolvedTagCommit,
89
+ metadata.latestCheckedCommit,
90
+ ];
91
+ if (new Set(revisions).size !== revisions.length) fail("upstream revision fields must be distinct");
92
+ if (!Array.isArray(metadata.selected) || metadata.selected.length === 0) fail("selected must be non-empty");
93
+ if (!Array.isArray(metadata.excluded) || metadata.excluded.length === 0) fail("excluded must be non-empty");
94
+
95
+ for (const [index, item] of metadata.selected.entries()) {
96
+ for (const field of ["upstreamSkill", "upstreamPath", "upstreamInvocation", "localCatalogId", "localResource"]) {
97
+ requireString(item[field], `selected[${index}].${field}`);
98
+ }
99
+ if (!new Set(["model", "user"]).has(item.upstreamInvocation)) {
100
+ fail(`selected[${index}].upstreamInvocation must be model or user`);
101
+ }
102
+ if (item.upstreamPath !== `skills/${item.upstreamPath.split("/")[1]}/${item.upstreamSkill}/SKILL.md`) {
103
+ fail(`selected[${index}] upstream path does not match its skill name`);
104
+ }
105
+ if (item.localCatalogId !== item.localResource.split("/").at(-1).replace(/\.md$/, "")) {
106
+ fail(`selected[${index}] local catalog id does not match its resource`);
107
+ }
108
+ await requireFile(resolve(packageDirectory, item.localResource), `selected[${index}].localResource`);
109
+ }
110
+
111
+ await validateCatalogMappings(metadata);
112
+
113
+ for (const [index, item] of metadata.excluded.entries()) {
114
+ for (const field of ["upstreamSkill", "upstreamPath", "reason"]) {
115
+ requireString(item[field], `excluded[${index}].${field}`);
116
+ }
117
+ if (item.upstreamPath.split("/").at(-2) !== item.upstreamSkill) {
118
+ fail(`excluded[${index}] upstream path does not match its skill name`);
119
+ }
120
+ }
121
+
122
+ const all = [...metadata.selected, ...metadata.excluded];
123
+ for (const [label, values] of [
124
+ ["upstream skill", all.map((item) => item.upstreamSkill)],
125
+ ["upstream path", all.map((item) => item.upstreamPath)],
126
+ ["local catalog id", metadata.selected.map((item) => item.localCatalogId)],
127
+ ["local resource", metadata.selected.map((item) => item.localResource)],
128
+ ]) {
129
+ const repeated = duplicates(values);
130
+ if (repeated.length > 0) fail(`duplicate ${label}: ${repeated.join(", ")}`);
131
+ }
132
+ }
133
+
134
+ async function git(upstream, ...args) {
135
+ try {
136
+ const { stdout } = await execFileAsync("git", ["-C", upstream, ...args], { encoding: "utf8" });
137
+ return stdout.trim();
138
+ } catch (error) {
139
+ fail(`could not inspect upstream Git revision: ${error instanceof Error ? error.message : String(error)}`);
140
+ }
141
+ }
142
+
143
+ async function validateUpstreamRevisions(metadata, upstream) {
144
+ const head = await git(upstream, "rev-parse", "HEAD");
145
+ if (head !== metadata.latestCheckedCommit) {
146
+ fail(`upstream HEAD is ${head}, metadata latestCheckedCommit is ${metadata.latestCheckedCommit}`);
147
+ }
148
+ const compared = await git(upstream, "rev-parse", `${metadata.comparedCommit}^{commit}`);
149
+ if (compared !== metadata.comparedCommit) fail(`comparedCommit does not resolve to itself: ${compared}`);
150
+ const tagCommit = await git(upstream, "rev-parse", `${metadata.comparisonTag}^{commit}`);
151
+ if (tagCommit !== metadata.resolvedTagCommit) {
152
+ fail(`${metadata.comparisonTag} resolves to ${tagCommit}, metadata resolvedTagCommit is ${metadata.resolvedTagCommit}`);
153
+ }
154
+ }
155
+
156
+ async function compareUpstream(metadata, upstreamArgument) {
157
+ const upstream = isAbsolute(upstreamArgument) ? upstreamArgument : resolve(process.cwd(), upstreamArgument);
158
+ await validateUpstreamRevisions(metadata, upstream);
159
+ const entries = [...metadata.selected, ...metadata.excluded];
160
+ const declaredPaths = new Set(entries.map((item) => item.upstreamPath));
161
+ let exact = 0;
162
+ let adapted = 0;
163
+
164
+ for (const item of entries) {
165
+ const sourcePath = resolve(upstream, item.upstreamPath);
166
+ await requireFile(sourcePath, item.upstreamPath);
167
+ const skill = splitSkill(await readFile(sourcePath, "utf8"));
168
+ if (skill.name !== item.upstreamSkill) fail(`${item.upstreamPath} declares name ${skill.name}`);
169
+ if (item.localResource && skill.invocation !== item.upstreamInvocation) {
170
+ fail(`${item.upstreamPath} invocation is ${skill.invocation}, metadata says ${item.upstreamInvocation}`);
171
+ }
172
+ if (item.localResource) {
173
+ const local = await readFile(resolve(packageDirectory, item.localResource), "utf8");
174
+ if (normalizedBody(local) === normalizedBody(skill.body)) exact += 1;
175
+ else adapted += 1;
176
+ }
177
+ }
178
+
179
+ const { readdir } = await import("node:fs/promises");
180
+ async function visit(directory) {
181
+ const found = [];
182
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
183
+ const path = resolve(directory, entry.name);
184
+ if (entry.isDirectory()) found.push(...await visit(path));
185
+ else if (entry.name === "SKILL.md") found.push(path.slice(upstream.length + 1).replaceAll("\\", "/"));
186
+ }
187
+ return found;
188
+ }
189
+ const upstreamPaths = await visit(resolve(upstream, "skills"));
190
+ const undeclared = upstreamPaths.filter((path) => !declaredPaths.has(path));
191
+ const missing = [...declaredPaths].filter((path) => !upstreamPaths.includes(path));
192
+ if (undeclared.length > 0) fail(`unclassified upstream skills: ${undeclared.join(", ")}`);
193
+ if (missing.length > 0) fail(`declared upstream skills not found: ${missing.join(", ")}`);
194
+ return { upstreamSkills: upstreamPaths.length, content: { exact, adapted } };
195
+ }
196
+
197
+ try {
198
+ const args = parseArguments(process.argv.slice(2));
199
+ const metadata = JSON.parse(await readFile(selectionPath, "utf8"));
200
+ await validateMetadata(metadata);
201
+ const comparison = args.upstream ? await compareUpstream(metadata, args.upstream) : {};
202
+ console.log(JSON.stringify({
203
+ ok: true,
204
+ upstreamCompared: Boolean(args.upstream),
205
+ selected: metadata.selected.length,
206
+ excluded: metadata.excluded.length,
207
+ ...comparison,
208
+ }));
209
+ } catch (error) {
210
+ console.error(error instanceof Error ? error.message : String(error));
211
+ process.exitCode = 1;
212
+ }