Skip to content
File

Blob: .opencode/tools/next-capnp-ordinal.ts

typescript229 lines
1import { tool } from '@opencode-ai/plugin';
2import path from 'path';
3import { runCapnpcCapnp, resolveCapnpPath } from './capnp.ts';
4 
5export default tool({
6 description:
7 "Find the next available field ordinal (@N) in a Cap'n Proto struct. " +
8 'Uses the capnp compiler (capnpc-capnp) to parse the schema canonically. ' +
9 'Use before adding new fields to .capnp files (especially compatibility-date.capnp) ' +
10 'to avoid ordinal collisions.',
11 args: {
12 file: tool.schema
13 .string()
14 .describe('Path to .capnp file (absolute, or relative to project root)'),
15 struct: tool.schema
16 .string()
17 .optional()
18 .describe(
19 "Struct name (e.g., 'CompatibilityFlags'). Omit to list all structs."
20 ),
21 },
22 async execute(args, ctx) {
23 const root = ctx.worktree || ctx.directory;
24 const filePath = resolveCapnpPath(root, args.file);
25 
26 const result = await runCapnpcCapnp(root, filePath);
27 if (typeof result !== 'string') return result.error;
28 
29 const structs = parseCapnpOutput(result);
30 
31 if (!args.struct) {
32 return listStructs(structs, filePath);
33 }
34 
35 // Support dotted names like "Worker.Module"
36 const target =
37 structs.find((s) => s.qualifiedName === args.struct) ||
38 structs.find((s) => s.name === args.struct);
39 
40 if (!target) {
41 const available = structs.map((s) => s.qualifiedName).join(', ');
42 return `Struct '${args.struct}' not found in ${path.basename(filePath)}\n\nAvailable: ${available}`;
43 }
44 
45 return formatResult(target, filePath);
46 },
47});
48 
49// --- Output parsing ---
50 
51interface FieldInfo {
52 name: string;
53 ordinal: number;
54 type: string;
55 annotations: string[];
56 raw: string;
57}
58 
59interface StructInfo {
60 name: string;
61 qualifiedName: string;
62 fields: FieldInfo[];
63 declaredAnnotations: string[];
64}
65 
66function parseCapnpOutput(output: string): StructInfo[] {
67 const lines = output.split('\n');
68 const allStructs: StructInfo[] = [];
69 
70 const nameStack: string[] = [];
71 let currentStruct: StructInfo | null = null;
72 
73 let depth = 0;
74 const structDepths: number[] = [];
75 
76 for (const line of lines) {
77 const opens = (line.match(/\{/g) || []).length;
78 const closes = (line.match(/\}/g) || []).length;
79 
80 // Check for struct opening
81 const structMatch = line.match(/struct\s+(\w+)\s+@0x[\da-fA-F]+\s*\{/);
82 if (structMatch) {
83 if (currentStruct) {
84 allStructs.push(currentStruct);
85 }
86 
87 const name = structMatch[1];
88 nameStack.push(name);
89 structDepths.push(depth);
90 
91 currentStruct = {
92 name,
93 qualifiedName: nameStack.join('.'),
94 fields: [],
95 declaredAnnotations: [],
96 };
97 
98 depth += opens;
99 continue;
100 }
101 
102 depth += opens;
103 
104 // Field declarations: " fieldName @N :Type ..."
105 const fieldMatch = line.match(/^\s+(\w+)\s+@(\d+)\s+:(\S+)/);
106 if (fieldMatch && currentStruct) {
107 const annotations: string[] = [];
108 const annoRe = /\$(\w+)\(([^)]*)\)/g;
109 let m;
110 while ((m = annoRe.exec(line)) !== null) {
111 annotations.push(`$${m[1]}(${m[2]})`);
112 }
113 
114 const raw = line
115 .trim()
116 .replace(/\s+#\s.*$/, '')
117 .replace(/;$/, '');
118 
119 currentStruct.fields.push({
120 name: fieldMatch[1],
121 ordinal: parseInt(fieldMatch[2], 10),
122 type: fieldMatch[3].replace(/[;,]$/, ''),
123 annotations,
124 raw,
125 });
126 }
127 
128 // Annotation declarations inside a struct
129 const annoDecl = line.match(/^\s+annotation\s+(\w+)\s+@0x/);
130 if (annoDecl && currentStruct) {
131 currentStruct.declaredAnnotations.push(annoDecl[1]);
132 }
133 
134 depth -= closes;
135 
136 // Check if we've closed a struct
137 while (
138 structDepths.length > 0 &&
139 depth <= structDepths[structDepths.length - 1]
140 ) {
141 if (currentStruct) {
142 allStructs.push(currentStruct);
143 currentStruct = null;
144 }
145 structDepths.pop();
146 nameStack.pop();
147 
148 if (nameStack.length > 0) {
149 const parentName = nameStack.join('.');
150 const parentIdx = allStructs.findIndex(
151 (s) => s.qualifiedName === parentName
152 );
153 if (parentIdx >= 0) {
154 currentStruct = allStructs[parentIdx];
155 allStructs.splice(parentIdx, 1);
156 }
157 }
158 }
159 }
160 
161 if (currentStruct) {
162 allStructs.push(currentStruct);
163 }
164 
165 return allStructs;
166}
167 
168// --- Formatting ---
169 
170function listStructs(structs: StructInfo[], filePath: string): string {
171 if (structs.length === 0) {
172 return `No structs found in ${path.basename(filePath)}`;
173 }
174 let result = `## Structs in ${path.basename(filePath)}\n\n`;
175 for (const s of structs) {
176 if (s.fields.length === 0) {
177 result += `- **${s.qualifiedName}**: no fields\n`;
178 } else {
179 const max = Math.max(...s.fields.map((f) => f.ordinal));
180 result += `- **${s.qualifiedName}**: ${s.fields.length} fields, highest @${max}, next @${max + 1}\n`;
181 }
182 }
183 return result;
184}
185 
186function formatResult(target: StructInfo, filePath: string): string {
187 if (target.fields.length === 0) {
188 return `Struct '${target.qualifiedName}' in ${path.basename(filePath)} has no fields.`;
189 }
190 
191 const sorted = [...target.fields].sort((a, b) => a.ordinal - b.ordinal);
192 const maxOrdinal = sorted[sorted.length - 1].ordinal;
193 const nextOrdinal = maxOrdinal + 1;
194 
195 const used = new Set(target.fields.map((f) => f.ordinal));
196 const gaps: number[] = [];
197 for (let i = 0; i <= maxOrdinal; i++) {
198 if (!used.has(i)) gaps.push(i);
199 }
200 
201 const lastFields = sorted.slice(-5);
202 
203 let out = `## ${target.qualifiedName} in ${path.basename(filePath)}\n\n`;
204 out += `**Next available ordinal: @${nextOrdinal}**\n\n`;
205 out += `- Total fields: ${target.fields.length}\n`;
206 out += `- Highest ordinal: @${maxOrdinal} (\`${sorted[sorted.length - 1].name}\`)\n`;
207 
208 if (gaps.length > 0) {
209 out += `- Gaps: ${gaps.map((g) => `@${g}`).join(', ')}\n`;
210 out += ` (Likely obsolete -- do NOT reuse)\n`;
211 }
212 
213 if (target.declaredAnnotations.length > 0) {
214 out += `- Annotations: ${target.declaredAnnotations.join(', ')}\n`;
215 }
216 
217 out += `\n### Last ${lastFields.length} fields\n\n`;
218 for (const f of lastFields) {
219 out += `- \`${f.raw}\`\n`;
220 }
221 
222 out += `\n### Usage\n\n`;
223 out += '```capnp\n';
224 out += ` yourNewField @${nextOrdinal} :Bool\n`;
225 out += '```\n';
226 
227 return out;
228}