agentsmd-hierarchy 1.0.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.
- package/README.md +212 -0
- package/agentsmd-hierarchy/AGENTS.md +21 -0
- package/agentsmd-hierarchy/SKILL.md +82 -0
- package/agentsmd-hierarchy/agents/AGENTS.md +16 -0
- package/agentsmd-hierarchy/agents/openai.yaml +4 -0
- package/agentsmd-hierarchy/references/AGENTS.md +20 -0
- package/agentsmd-hierarchy/references/agents-convention.md +94 -0
- package/agentsmd-hierarchy/references/example-complex-package-root.md +37 -0
- package/agentsmd-hierarchy/references/example-complex-source-directory.md +34 -0
- package/agentsmd-hierarchy/references/example-simple-flat-directory.md +23 -0
- package/agentsmd-hierarchy/references/example-simple-test-helpers.md +22 -0
- package/agentsmd-hierarchy/scripts/AGENTS.md +21 -0
- package/agentsmd-hierarchy/scripts/cli-logger.mjs +1 -0
- package/agentsmd-hierarchy/scripts/cli-prompts.mjs +105 -0
- package/agentsmd-hierarchy/scripts/lib/AGENTS.md +21 -0
- package/agentsmd-hierarchy/scripts/lib/cli-logger.mjs +149 -0
- package/agentsmd-hierarchy/scripts/lib/errors.mjs +12 -0
- package/agentsmd-hierarchy/scripts/lib/install-core.mjs +689 -0
- package/agentsmd-hierarchy/scripts/lib/program.mjs +188 -0
- package/agentsmd-hierarchy/scripts/lib/validate-agents-core.mjs +1245 -0
- package/agentsmd-hierarchy/scripts/scaffold-agents.mjs +72 -0
- package/agentsmd-hierarchy/scripts/sync-agents.mjs +11 -0
- package/agentsmd-hierarchy/scripts/validate-agents.mjs +6 -0
- package/bin/AGENTS.md +16 -0
- package/bin/agentsmd-hierarchy.mjs +6 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1245 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createLogger } from './cli-logger.mjs';
|
|
12
|
+
import { CommandError, isCommandError } from './errors.mjs';
|
|
13
|
+
|
|
14
|
+
const GENERATED_FILE_NAMES = new Set(['package-lock.json', 'pnpm-lock.yaml']);
|
|
15
|
+
const DEFAULT_EXCLUDED_SCAN_DIRECTORIES = ['.git', 'node_modules'];
|
|
16
|
+
|
|
17
|
+
function extractMarkdownSection(content, heading) {
|
|
18
|
+
const sectionStart = content.indexOf(`## ${heading}\n`);
|
|
19
|
+
if (sectionStart === -1) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const nextSectionStart = content.indexOf('\n## ', sectionStart + 1);
|
|
24
|
+
return content.slice(
|
|
25
|
+
sectionStart,
|
|
26
|
+
nextSectionStart === -1 ? content.length : nextSectionStart,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getRepoRoot(env, cwd) {
|
|
31
|
+
if (env.REPO_ROOT) {
|
|
32
|
+
return path.resolve(env.REPO_ROOT);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return cwd;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getExcludedScanDirectories(repoRoot) {
|
|
39
|
+
const excludedDirectories = new Set(DEFAULT_EXCLUDED_SCAN_DIRECTORIES);
|
|
40
|
+
const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
|
|
41
|
+
|
|
42
|
+
if (!existsSync(rootAgentsPath)) {
|
|
43
|
+
return [...excludedDirectories];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const agentsHierarchySection = extractMarkdownSection(
|
|
47
|
+
readFileSync(rootAgentsPath, 'utf8'),
|
|
48
|
+
'AGENTS Hierarchy',
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (!agentsHierarchySection) {
|
|
52
|
+
return [...excludedDirectories];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const line of agentsHierarchySection.split('\n')) {
|
|
56
|
+
if (!line.includes('Exclude')) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const matches = line.matchAll(/`([^`]+)`/g);
|
|
61
|
+
for (const match of matches) {
|
|
62
|
+
excludedDirectories.add(match[1]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return [...excludedDirectories];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isExcludedAgentsDirectory(directoryPath) {
|
|
70
|
+
return (
|
|
71
|
+
directoryPath === '.codex/skills' ||
|
|
72
|
+
directoryPath.startsWith('.codex/skills/')
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isExcludedScanPath(relativePath, excludedScanDirectories) {
|
|
77
|
+
return excludedScanDirectories.some(
|
|
78
|
+
(excludedPath) =>
|
|
79
|
+
relativePath === excludedPath ||
|
|
80
|
+
relativePath.startsWith(`${excludedPath}/`),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isGeneratedFile(fileName) {
|
|
85
|
+
return (
|
|
86
|
+
fileName.includes('.gen.') ||
|
|
87
|
+
GENERATED_FILE_NAMES.has(fileName) ||
|
|
88
|
+
fileName.endsWith('.tgz')
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeInputPath(rawValue, repoRoot) {
|
|
93
|
+
if (!rawValue || rawValue === '.') {
|
|
94
|
+
return {
|
|
95
|
+
scopePath: '.',
|
|
96
|
+
scopeType: 'directory',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (path.isAbsolute(rawValue)) {
|
|
101
|
+
throw new CommandError('Pass a repo-relative path, not an absolute path.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const normalizedPath = path.posix.normalize(rawValue.replaceAll('\\', '/'));
|
|
105
|
+
if (normalizedPath === '..' || normalizedPath.startsWith('../')) {
|
|
106
|
+
throw new CommandError('Path must stay inside the repository root.');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const absolutePath = path.resolve(repoRoot, normalizedPath);
|
|
110
|
+
if (!existsSync(absolutePath)) {
|
|
111
|
+
throw new CommandError(`Path not found: ${rawValue}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const stats = statSync(absolutePath);
|
|
115
|
+
if (stats.isDirectory()) {
|
|
116
|
+
return {
|
|
117
|
+
scopePath: normalizedPath === '' ? '.' : normalizedPath,
|
|
118
|
+
scopeType: 'directory',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (path.posix.basename(normalizedPath) !== 'AGENTS.md') {
|
|
123
|
+
throw new CommandError('Pass a directory or an AGENTS.md file.');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
scopePath: normalizedPath,
|
|
128
|
+
scopeType: 'file',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function parseValidateArguments(argv, repoRoot) {
|
|
133
|
+
let scope = {
|
|
134
|
+
scopePath: '.',
|
|
135
|
+
scopeType: 'directory',
|
|
136
|
+
};
|
|
137
|
+
let strictPlaceholders = false;
|
|
138
|
+
let debug = false;
|
|
139
|
+
let mode = null;
|
|
140
|
+
|
|
141
|
+
for (const argument of argv) {
|
|
142
|
+
if (argument === '--check') {
|
|
143
|
+
if (mode && mode !== 'check') {
|
|
144
|
+
throw new CommandError('Choose either --check or --fix, not both.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
mode = 'check';
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (argument === '--fix') {
|
|
152
|
+
if (mode && mode !== 'fix') {
|
|
153
|
+
throw new CommandError('Choose either --check or --fix, not both.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
mode = 'fix';
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (argument === '--strict-placeholders') {
|
|
161
|
+
strictPlaceholders = true;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (argument === '--debug') {
|
|
166
|
+
debug = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (argument.startsWith('--')) {
|
|
171
|
+
throw new CommandError(`Unknown option: ${argument}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (scope.scopePath !== '.' || scope.scopeType !== 'directory') {
|
|
175
|
+
throw new CommandError(
|
|
176
|
+
'Usage: node .codex/skills/agentsmd-hierarchy/scripts/validate-agents.mjs [--check|--fix] [repo-relative-path-or-agents-file] [--strict-placeholders] [--debug]',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
scope = normalizeInputPath(argument, repoRoot);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
debug,
|
|
185
|
+
mode: mode ?? 'check',
|
|
186
|
+
scope,
|
|
187
|
+
strictPlaceholders,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function collectFilesFromGit(repoRoot) {
|
|
192
|
+
try {
|
|
193
|
+
const output = execFileSync(
|
|
194
|
+
'git',
|
|
195
|
+
['ls-files', '--cached', '--others', '--exclude-standard'],
|
|
196
|
+
{
|
|
197
|
+
cwd: repoRoot,
|
|
198
|
+
encoding: 'utf8',
|
|
199
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return output
|
|
204
|
+
.split('\n')
|
|
205
|
+
.map((value) => value.trim())
|
|
206
|
+
.filter(Boolean)
|
|
207
|
+
.map((filePath) => filePath.replaceAll('\\', '/'));
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function collectFilesFromFilesystem(repoRoot, excludedScanDirectories) {
|
|
214
|
+
const filePaths = [];
|
|
215
|
+
|
|
216
|
+
function visitDirectory(directoryPath) {
|
|
217
|
+
const absoluteDirectoryPath =
|
|
218
|
+
directoryPath === '.' ? repoRoot : path.resolve(repoRoot, directoryPath);
|
|
219
|
+
|
|
220
|
+
for (const entry of readdirSync(absoluteDirectoryPath, {
|
|
221
|
+
withFileTypes: true,
|
|
222
|
+
})) {
|
|
223
|
+
const relativePath =
|
|
224
|
+
directoryPath === '.' ? entry.name : `${directoryPath}/${entry.name}`;
|
|
225
|
+
|
|
226
|
+
if (entry.isDirectory()) {
|
|
227
|
+
if (isExcludedScanPath(relativePath, excludedScanDirectories)) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
visitDirectory(relativePath);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
filePaths.push(relativePath);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
visitDirectory('.');
|
|
240
|
+
|
|
241
|
+
return filePaths.sort();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function collectRepositoryFiles(repoRoot, excludedScanDirectories) {
|
|
245
|
+
const gitFiles = collectFilesFromGit(repoRoot);
|
|
246
|
+
if (gitFiles) {
|
|
247
|
+
return {
|
|
248
|
+
filePaths: gitFiles,
|
|
249
|
+
source: 'git',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
filePaths: collectFilesFromFilesystem(repoRoot, excludedScanDirectories),
|
|
255
|
+
source: 'filesystem',
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getVisibleAgentsDirectories(allPaths) {
|
|
260
|
+
const directories = new Set();
|
|
261
|
+
|
|
262
|
+
for (const filePath of allPaths) {
|
|
263
|
+
if (path.posix.basename(filePath) !== 'AGENTS.md') {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const directoryPath = path.posix.dirname(filePath);
|
|
268
|
+
const normalizedDirectory = directoryPath === '' ? '.' : directoryPath;
|
|
269
|
+
|
|
270
|
+
if (isExcludedAgentsDirectory(normalizedDirectory)) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
directories.add(normalizedDirectory);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return [...directories].sort();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getInventoryFiles(allPaths, excludedScanDirectories) {
|
|
281
|
+
return allPaths
|
|
282
|
+
.filter((filePath) => path.posix.basename(filePath) !== 'AGENTS.md')
|
|
283
|
+
.filter(
|
|
284
|
+
(filePath) => !isExcludedScanPath(filePath, excludedScanDirectories),
|
|
285
|
+
)
|
|
286
|
+
.sort();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getScopeDirectory(scope) {
|
|
290
|
+
if (scope.scopeType === 'file') {
|
|
291
|
+
const directoryPath = path.posix.dirname(scope.scopePath);
|
|
292
|
+
return directoryPath === '' ? '.' : directoryPath;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return scope.scopePath;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function getExplicitDirectories(scope) {
|
|
299
|
+
const directories = new Set(['.']);
|
|
300
|
+
let currentDirectory = getScopeDirectory(scope);
|
|
301
|
+
|
|
302
|
+
while (true) {
|
|
303
|
+
if (!isExcludedAgentsDirectory(currentDirectory)) {
|
|
304
|
+
directories.add(currentDirectory);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (currentDirectory === '.') {
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const parentDirectory = path.posix.dirname(currentDirectory);
|
|
312
|
+
currentDirectory = parentDirectory === '' ? '.' : parentDirectory;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return [...directories].sort();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getRequiredDirectories(files, explicitDirectories) {
|
|
319
|
+
const directories = new Set(['.', ...explicitDirectories]);
|
|
320
|
+
|
|
321
|
+
for (const filePath of files) {
|
|
322
|
+
let currentDirectory = path.posix.dirname(filePath);
|
|
323
|
+
directories.add(currentDirectory);
|
|
324
|
+
|
|
325
|
+
while (currentDirectory !== '.') {
|
|
326
|
+
currentDirectory = path.posix.dirname(currentDirectory);
|
|
327
|
+
directories.add(currentDirectory);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return [...directories]
|
|
332
|
+
.map((directoryPath) => (directoryPath === '' ? '.' : directoryPath))
|
|
333
|
+
.filter((directoryPath) => !isExcludedAgentsDirectory(directoryPath))
|
|
334
|
+
.sort();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getDirectoriesToSync(
|
|
338
|
+
requiredDirectories,
|
|
339
|
+
existingAgentsDirectories,
|
|
340
|
+
scope,
|
|
341
|
+
explicitDirectories,
|
|
342
|
+
) {
|
|
343
|
+
const scopeDirectory = getScopeDirectory(scope);
|
|
344
|
+
const directories = new Set([
|
|
345
|
+
...requiredDirectories,
|
|
346
|
+
...existingAgentsDirectories,
|
|
347
|
+
...explicitDirectories,
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
if (scope.scopeType === 'file') {
|
|
351
|
+
return [...directories]
|
|
352
|
+
.filter((directoryPath) => directoryPath === scopeDirectory)
|
|
353
|
+
.sort();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (scopeDirectory === '.') {
|
|
357
|
+
return [...directories].sort();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return [...directories]
|
|
361
|
+
.filter(
|
|
362
|
+
(directoryPath) =>
|
|
363
|
+
directoryPath === '.' ||
|
|
364
|
+
directoryPath === scopeDirectory ||
|
|
365
|
+
directoryPath.startsWith(`${scopeDirectory}/`) ||
|
|
366
|
+
scopeDirectory.startsWith(`${directoryPath}/`),
|
|
367
|
+
)
|
|
368
|
+
.sort();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function getImmediateChildren(
|
|
372
|
+
directoryPath,
|
|
373
|
+
inventoryFiles,
|
|
374
|
+
excludedScanDirectories,
|
|
375
|
+
explicitDirectories,
|
|
376
|
+
) {
|
|
377
|
+
const directoryPrefix = directoryPath === '.' ? '' : `${directoryPath}/`;
|
|
378
|
+
const childDirectories = new Set();
|
|
379
|
+
const childFiles = new Set();
|
|
380
|
+
|
|
381
|
+
for (const filePath of inventoryFiles) {
|
|
382
|
+
if (directoryPrefix && !filePath.startsWith(directoryPrefix)) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const relativePath = directoryPrefix
|
|
387
|
+
? filePath.slice(directoryPrefix.length)
|
|
388
|
+
: filePath;
|
|
389
|
+
|
|
390
|
+
if (!relativePath) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const segments = relativePath.split('/');
|
|
395
|
+
if (segments.length === 1) {
|
|
396
|
+
childFiles.add(segments[0]);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const childDirectory = segments[0];
|
|
401
|
+
const childDirectoryPath =
|
|
402
|
+
directoryPath === '.'
|
|
403
|
+
? childDirectory
|
|
404
|
+
: `${directoryPath}/${childDirectory}`;
|
|
405
|
+
|
|
406
|
+
if (isExcludedScanPath(childDirectoryPath, excludedScanDirectories)) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
childDirectories.add(childDirectory);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const explicitDirectory of explicitDirectories) {
|
|
414
|
+
if (explicitDirectory === '.' || explicitDirectory === directoryPath) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const parentDirectory = path.posix.dirname(explicitDirectory);
|
|
419
|
+
const normalizedParent = parentDirectory === '' ? '.' : parentDirectory;
|
|
420
|
+
if (normalizedParent !== directoryPath) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const childName = path.posix.basename(explicitDirectory);
|
|
425
|
+
if (
|
|
426
|
+
childName &&
|
|
427
|
+
!isExcludedScanPath(explicitDirectory, excludedScanDirectories)
|
|
428
|
+
) {
|
|
429
|
+
childDirectories.add(childName);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
directories: [...childDirectories].sort(),
|
|
435
|
+
files: [...childFiles].sort(),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function getSectionRange(content, heading) {
|
|
440
|
+
const marker = `## ${heading}\n`;
|
|
441
|
+
const sectionStart = content.indexOf(marker);
|
|
442
|
+
if (sectionStart === -1) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const bodyStart = sectionStart + marker.length;
|
|
447
|
+
const nextSectionStart = content.indexOf('\n## ', bodyStart);
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
bodyStart,
|
|
451
|
+
end: nextSectionStart === -1 ? content.length : nextSectionStart + 1,
|
|
452
|
+
start: sectionStart,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function extractSection(content, heading) {
|
|
457
|
+
const sectionRange = getSectionRange(content, heading);
|
|
458
|
+
if (!sectionRange) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return content.slice(sectionRange.bodyStart, sectionRange.end);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function extractSectionBody(content, heading) {
|
|
466
|
+
const sectionContent = extractSection(content, heading);
|
|
467
|
+
return sectionContent === null ? null : sectionContent.trim();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function extractTitle(content) {
|
|
471
|
+
const titleMatch = content.match(/^# (.+)$/m);
|
|
472
|
+
return titleMatch?.[1] ?? null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function extractOverview(content, directoriesRange) {
|
|
476
|
+
const titleMatch = content.match(/^# .+\n?/);
|
|
477
|
+
if (!titleMatch || !directoriesRange) {
|
|
478
|
+
return '';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return content.slice(titleMatch[0].length, directoriesRange.start).trim();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function getTopLevelBulletLines(sectionBody) {
|
|
485
|
+
if (!sectionBody) {
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return sectionBody.split('\n').filter((line) => line.startsWith('- '));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function validateEntrySection(sectionName, sectionBody, kind) {
|
|
493
|
+
const issues = [];
|
|
494
|
+
|
|
495
|
+
if (sectionBody === null) {
|
|
496
|
+
return issues;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!sectionBody) {
|
|
500
|
+
issues.push(
|
|
501
|
+
`"${sectionName}" should contain "- None." or one or more formatted entries`,
|
|
502
|
+
);
|
|
503
|
+
return issues;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const topLevelBulletLines = getTopLevelBulletLines(sectionBody);
|
|
507
|
+
|
|
508
|
+
if (topLevelBulletLines.length === 0) {
|
|
509
|
+
issues.push(
|
|
510
|
+
`"${sectionName}" should contain "- None." or one or more formatted entries`,
|
|
511
|
+
);
|
|
512
|
+
return issues;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const noneLines = topLevelBulletLines.filter((line) => line === '- None.');
|
|
516
|
+
if (noneLines.length > 0 && topLevelBulletLines.length > 1) {
|
|
517
|
+
issues.push(
|
|
518
|
+
`"${sectionName}" must use either "- None." or listed entries, not both`,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (
|
|
523
|
+
topLevelBulletLines.length === 1 &&
|
|
524
|
+
topLevelBulletLines[0] === '- None.'
|
|
525
|
+
) {
|
|
526
|
+
return issues;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const line of topLevelBulletLines) {
|
|
530
|
+
if (line === '- None.') {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const entryMatch = line.match(/^- `([^`]+)`:(.+)$/);
|
|
535
|
+
if (!entryMatch) {
|
|
536
|
+
issues.push(`malformed entry in "${sectionName}": ${line}`);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const [, name, description] = entryMatch;
|
|
541
|
+
if (!description.trim()) {
|
|
542
|
+
issues.push(
|
|
543
|
+
`entry in "${sectionName}" is missing a description: ${line}`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (kind === 'directory' && !name.endsWith('/')) {
|
|
548
|
+
issues.push(
|
|
549
|
+
`directory entries in "${sectionName}" must end with "/": ${line}`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if ((kind === 'file' || kind === 'generated-file') && name.endsWith('/')) {
|
|
554
|
+
issues.push(
|
|
555
|
+
`file entries in "${sectionName}" must not end with "/": ${line}`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (
|
|
560
|
+
(kind === 'file' || kind === 'generated-file') &&
|
|
561
|
+
name === 'AGENTS.md'
|
|
562
|
+
) {
|
|
563
|
+
issues.push(`"${sectionName}" must not list "AGENTS.md" as a child file`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return issues;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function collectEntryNames(sectionBody) {
|
|
571
|
+
if (sectionBody === null || !sectionBody) {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const topLevelBulletLines = getTopLevelBulletLines(sectionBody);
|
|
576
|
+
if (
|
|
577
|
+
topLevelBulletLines.length === 1 &&
|
|
578
|
+
topLevelBulletLines[0] === '- None.'
|
|
579
|
+
) {
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const entryNames = [];
|
|
584
|
+
|
|
585
|
+
for (const line of topLevelBulletLines) {
|
|
586
|
+
if (line === '- None.') {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const entryMatch = line.match(/^- `([^`]+)`:(.+)$/);
|
|
591
|
+
if (!entryMatch) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
entryNames.push(entryMatch[1]);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return entryNames;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function collectDuplicateEntryNames(entryNames) {
|
|
602
|
+
const counts = new Map();
|
|
603
|
+
|
|
604
|
+
for (const entryName of entryNames) {
|
|
605
|
+
counts.set(entryName, (counts.get(entryName) ?? 0) + 1);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return [...counts.entries()]
|
|
609
|
+
.filter(([, count]) => count > 1)
|
|
610
|
+
.map(([entryName]) => entryName)
|
|
611
|
+
.sort();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function validateDocumentedInventory({
|
|
615
|
+
actualDirectories,
|
|
616
|
+
actualFiles,
|
|
617
|
+
compareDirectories,
|
|
618
|
+
compareFiles,
|
|
619
|
+
documentedDirectories,
|
|
620
|
+
documentedFiles,
|
|
621
|
+
}) {
|
|
622
|
+
const issues = [];
|
|
623
|
+
|
|
624
|
+
if (compareDirectories) {
|
|
625
|
+
const documentedDirectorySet = new Set(documentedDirectories);
|
|
626
|
+
const actualDirectorySet = new Set(actualDirectories);
|
|
627
|
+
|
|
628
|
+
for (const duplicateName of collectDuplicateEntryNames(
|
|
629
|
+
documentedDirectories,
|
|
630
|
+
)) {
|
|
631
|
+
issues.push(
|
|
632
|
+
`directory entry is listed more than once: \`${duplicateName}\``,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
for (const directoryName of documentedDirectories) {
|
|
637
|
+
if (!actualDirectorySet.has(directoryName)) {
|
|
638
|
+
issues.push(
|
|
639
|
+
`listed directory is not an immediate child: \`${directoryName}\``,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
for (const directoryName of actualDirectories) {
|
|
645
|
+
if (!documentedDirectorySet.has(directoryName)) {
|
|
646
|
+
issues.push(
|
|
647
|
+
`missing directory entry for immediate child: \`${directoryName}\``,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (compareFiles) {
|
|
654
|
+
const documentedFileSet = new Set(documentedFiles);
|
|
655
|
+
const actualFileSet = new Set(actualFiles);
|
|
656
|
+
|
|
657
|
+
for (const duplicateName of collectDuplicateEntryNames(documentedFiles)) {
|
|
658
|
+
issues.push(`file entry is listed more than once: \`${duplicateName}\``);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
for (const fileName of documentedFiles) {
|
|
662
|
+
if (!actualFileSet.has(fileName)) {
|
|
663
|
+
issues.push(`listed file is not an immediate child: \`${fileName}\``);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
for (const fileName of actualFiles) {
|
|
668
|
+
if (!documentedFileSet.has(fileName)) {
|
|
669
|
+
issues.push(
|
|
670
|
+
`missing file entry for immediate child: \`${fileName}\` (list it in "Files" or "Generated Files")`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return issues;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function collectPlaceholderLines(content) {
|
|
680
|
+
return content.split('\n').filter((line) => line.includes('TODO describe'));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function normalizeSectionBody(sectionContent) {
|
|
684
|
+
return sectionContent?.trim() ?? '';
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function parseEntryBlocks(sectionContent) {
|
|
688
|
+
if (!sectionContent) {
|
|
689
|
+
return new Map();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const trimmedContent = sectionContent.trim();
|
|
693
|
+
if (!trimmedContent || trimmedContent === '- None.') {
|
|
694
|
+
return new Map();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const blocks = new Map();
|
|
698
|
+
let currentName = null;
|
|
699
|
+
let currentLines = [];
|
|
700
|
+
|
|
701
|
+
for (const line of trimmedContent.split('\n')) {
|
|
702
|
+
const entryMatch = line.match(/^- `([^`]+)`:/);
|
|
703
|
+
|
|
704
|
+
if (entryMatch) {
|
|
705
|
+
if (currentName) {
|
|
706
|
+
blocks.set(currentName, currentLines.join('\n').trimEnd());
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
currentName = entryMatch[1];
|
|
710
|
+
currentLines = [line];
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (currentName) {
|
|
715
|
+
currentLines.push(line);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (currentName) {
|
|
720
|
+
blocks.set(currentName, currentLines.join('\n').trimEnd());
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return blocks;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function buildPlaceholderEntry(name, kind) {
|
|
727
|
+
if (kind === 'directory') {
|
|
728
|
+
return `- \`${name}\`: TODO describe this subdirectory.`;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (kind === 'generated-file') {
|
|
732
|
+
return `- \`${name}\`: TODO describe this generated file.\n Rules:\n - Prefer regenerating it instead of hand-editing when possible.`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return `- \`${name}\`: TODO describe this file.`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function rewriteEntryBlock(name, block, kind) {
|
|
739
|
+
if (!block) {
|
|
740
|
+
return buildPlaceholderEntry(name, kind);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const lines = block.split('\n');
|
|
744
|
+
const firstLineMatch = lines[0].match(/^- `[^`]+`:(.*)$/);
|
|
745
|
+
const description = firstLineMatch?.[1] ?? ' TODO describe this file.';
|
|
746
|
+
const rewrittenLines = [`- \`${name}\`:${description}`];
|
|
747
|
+
const remainingLines = lines.slice(1);
|
|
748
|
+
const hasRules = remainingLines.some((line) => line.trim() === 'Rules:');
|
|
749
|
+
|
|
750
|
+
rewrittenLines.push(...remainingLines);
|
|
751
|
+
|
|
752
|
+
if (kind === 'generated-file' && !hasRules) {
|
|
753
|
+
rewrittenLines.push(' Rules:');
|
|
754
|
+
rewrittenLines.push(
|
|
755
|
+
' - Prefer regenerating it instead of hand-editing when possible.',
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return rewrittenLines.join('\n').trimEnd();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function renderEntrySection(expectedNames, maps, kind) {
|
|
763
|
+
if (expectedNames.length === 0) {
|
|
764
|
+
return '- None.';
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const blocks = [];
|
|
768
|
+
|
|
769
|
+
for (const name of expectedNames) {
|
|
770
|
+
const existingBlock = maps.primary.get(name) ?? maps.secondary.get(name);
|
|
771
|
+
blocks.push(rewriteEntryBlock(name, existingBlock, kind));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return blocks.join('\n');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function renderAgentsContent(directoryPath, inventory, existingContent) {
|
|
778
|
+
const writingRulesRange = getSectionRange(existingContent, 'Writing Rules');
|
|
779
|
+
const overview = extractOverview(
|
|
780
|
+
existingContent,
|
|
781
|
+
getSectionRange(existingContent, 'Directories'),
|
|
782
|
+
);
|
|
783
|
+
const directoriesSection = normalizeSectionBody(
|
|
784
|
+
extractSection(existingContent, 'Directories'),
|
|
785
|
+
);
|
|
786
|
+
const filesSection = normalizeSectionBody(
|
|
787
|
+
extractSection(existingContent, 'Files'),
|
|
788
|
+
);
|
|
789
|
+
const generatedSection = normalizeSectionBody(
|
|
790
|
+
extractSection(existingContent, 'Generated Files'),
|
|
791
|
+
);
|
|
792
|
+
const writingRules = normalizeSectionBody(
|
|
793
|
+
extractSection(existingContent, 'Writing Rules'),
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const directoryBlocks = parseEntryBlocks(directoriesSection);
|
|
797
|
+
const fileBlocks = parseEntryBlocks(filesSection);
|
|
798
|
+
const generatedBlocks = parseEntryBlocks(generatedSection);
|
|
799
|
+
const generatedFiles = inventory.files.filter(isGeneratedFile);
|
|
800
|
+
const regularFiles = inventory.files.filter(
|
|
801
|
+
(fileName) => !generatedFiles.includes(fileName),
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
const renderedDirectories = renderEntrySection(
|
|
805
|
+
inventory.directories.map((directoryName) => `${directoryName}/`),
|
|
806
|
+
{ primary: directoryBlocks, secondary: new Map() },
|
|
807
|
+
'directory',
|
|
808
|
+
);
|
|
809
|
+
const renderedFiles = renderEntrySection(
|
|
810
|
+
regularFiles,
|
|
811
|
+
{ primary: fileBlocks, secondary: generatedBlocks },
|
|
812
|
+
'file',
|
|
813
|
+
);
|
|
814
|
+
const renderedGeneratedFiles =
|
|
815
|
+
generatedFiles.length > 0
|
|
816
|
+
? renderEntrySection(
|
|
817
|
+
generatedFiles,
|
|
818
|
+
{ primary: generatedBlocks, secondary: fileBlocks },
|
|
819
|
+
'generated-file',
|
|
820
|
+
)
|
|
821
|
+
: '';
|
|
822
|
+
const renderedWritingRules =
|
|
823
|
+
writingRules ||
|
|
824
|
+
'- [TODO: Add the main writing rule for this directory.]\n- [TODO: Note when this AGENTS.md must be updated.]';
|
|
825
|
+
const renderedOverview =
|
|
826
|
+
overview ||
|
|
827
|
+
'[TODO: Add a brief overview of what this directory contains and how it fits into the repo.]';
|
|
828
|
+
const trailingSections =
|
|
829
|
+
writingRulesRange && writingRulesRange.end < existingContent.length
|
|
830
|
+
? existingContent.slice(writingRulesRange.end).trim()
|
|
831
|
+
: '';
|
|
832
|
+
|
|
833
|
+
const coreContent = [
|
|
834
|
+
`# ${directoryPath}`,
|
|
835
|
+
renderedOverview,
|
|
836
|
+
'## Directories',
|
|
837
|
+
renderedDirectories,
|
|
838
|
+
'## Files',
|
|
839
|
+
renderedFiles,
|
|
840
|
+
generatedFiles.length > 0 ? '## Generated Files' : null,
|
|
841
|
+
generatedFiles.length > 0 ? renderedGeneratedFiles : null,
|
|
842
|
+
'## Writing Rules',
|
|
843
|
+
renderedWritingRules,
|
|
844
|
+
]
|
|
845
|
+
.filter(Boolean)
|
|
846
|
+
.join('\n\n');
|
|
847
|
+
|
|
848
|
+
return `${coreContent}${trailingSections ? `\n\n${trailingSections}` : ''}\n`;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function syncAgentsFile(repoRoot, directoryPath, inventory, mode) {
|
|
852
|
+
const agentsPath =
|
|
853
|
+
directoryPath === '.'
|
|
854
|
+
? path.join(repoRoot, 'AGENTS.md')
|
|
855
|
+
: path.join(repoRoot, directoryPath, 'AGENTS.md');
|
|
856
|
+
const existingContent = existsSync(agentsPath)
|
|
857
|
+
? readFileSync(agentsPath, 'utf8')
|
|
858
|
+
: '';
|
|
859
|
+
const nextContent = renderAgentsContent(
|
|
860
|
+
directoryPath,
|
|
861
|
+
inventory,
|
|
862
|
+
existingContent,
|
|
863
|
+
);
|
|
864
|
+
const hadExistingFile = existsSync(agentsPath);
|
|
865
|
+
|
|
866
|
+
if (existingContent === nextContent) {
|
|
867
|
+
return {
|
|
868
|
+
changed: false,
|
|
869
|
+
created: false,
|
|
870
|
+
path: agentsPath,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (mode === 'fix') {
|
|
875
|
+
mkdirSync(path.dirname(agentsPath), { recursive: true });
|
|
876
|
+
writeFileSync(agentsPath, nextContent, 'utf8');
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return {
|
|
880
|
+
changed: true,
|
|
881
|
+
created: !hadExistingFile,
|
|
882
|
+
path: agentsPath,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function validateAgentsFile(repoRoot, agentsPath, inventoryFiles, options) {
|
|
887
|
+
const issues = [];
|
|
888
|
+
const warnings = [];
|
|
889
|
+
const absoluteAgentsPath = path.join(repoRoot, agentsPath);
|
|
890
|
+
const content = readFileSync(absoluteAgentsPath, 'utf8');
|
|
891
|
+
const directoryPath = path.posix.dirname(agentsPath) || '.';
|
|
892
|
+
|
|
893
|
+
const title = extractTitle(content);
|
|
894
|
+
const directoriesRange = getSectionRange(content, 'Directories');
|
|
895
|
+
const filesRange = getSectionRange(content, 'Files');
|
|
896
|
+
const generatedRange = getSectionRange(content, 'Generated Files');
|
|
897
|
+
const writingRulesRange = getSectionRange(content, 'Writing Rules');
|
|
898
|
+
const directoriesBody = extractSectionBody(content, 'Directories');
|
|
899
|
+
const filesBody = extractSectionBody(content, 'Files');
|
|
900
|
+
const generatedBody = extractSectionBody(content, 'Generated Files');
|
|
901
|
+
|
|
902
|
+
if (title !== directoryPath) {
|
|
903
|
+
issues.push(`title should be "# ${directoryPath}"`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (!directoriesRange) {
|
|
907
|
+
issues.push('missing "## Directories" section');
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
if (!filesRange) {
|
|
911
|
+
issues.push('missing "## Files" section');
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (!writingRulesRange) {
|
|
915
|
+
issues.push('missing "## Writing Rules" section');
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (
|
|
919
|
+
directoriesRange &&
|
|
920
|
+
filesRange &&
|
|
921
|
+
directoriesRange.start > filesRange.start
|
|
922
|
+
) {
|
|
923
|
+
issues.push('"## Directories" must appear before "## Files"');
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (filesRange && generatedRange && filesRange.start > generatedRange.start) {
|
|
927
|
+
issues.push('"## Generated Files" must appear after "## Files"');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (
|
|
931
|
+
generatedRange &&
|
|
932
|
+
writingRulesRange &&
|
|
933
|
+
generatedRange.start > writingRulesRange.start
|
|
934
|
+
) {
|
|
935
|
+
issues.push('"## Generated Files" must appear before "## Writing Rules"');
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (
|
|
939
|
+
!generatedRange &&
|
|
940
|
+
filesRange &&
|
|
941
|
+
writingRulesRange &&
|
|
942
|
+
filesRange.start > writingRulesRange.start
|
|
943
|
+
) {
|
|
944
|
+
issues.push('"## Writing Rules" must appear after "## Files"');
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const overview = extractOverview(content, directoriesRange);
|
|
948
|
+
if (!overview) {
|
|
949
|
+
issues.push('missing overview paragraph before the first section');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
issues.push(
|
|
953
|
+
...validateEntrySection('Directories', directoriesBody, 'directory'),
|
|
954
|
+
);
|
|
955
|
+
issues.push(...validateEntrySection('Files', filesBody, 'file'));
|
|
956
|
+
issues.push(
|
|
957
|
+
...validateEntrySection('Generated Files', generatedBody, 'generated-file'),
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
if (directoriesBody !== null || filesBody !== null) {
|
|
961
|
+
const immediateInventory = getImmediateChildren(
|
|
962
|
+
directoryPath,
|
|
963
|
+
inventoryFiles,
|
|
964
|
+
options.excludedScanDirectories,
|
|
965
|
+
options.explicitDirectories,
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
issues.push(
|
|
969
|
+
...validateDocumentedInventory({
|
|
970
|
+
actualDirectories: immediateInventory.directories.map(
|
|
971
|
+
(childName) => `${childName}/`,
|
|
972
|
+
),
|
|
973
|
+
actualFiles: immediateInventory.files,
|
|
974
|
+
compareDirectories: directoriesBody !== null,
|
|
975
|
+
compareFiles: filesBody !== null,
|
|
976
|
+
documentedDirectories:
|
|
977
|
+
directoriesBody === null ? [] : collectEntryNames(directoriesBody),
|
|
978
|
+
documentedFiles:
|
|
979
|
+
filesBody === null
|
|
980
|
+
? []
|
|
981
|
+
: [
|
|
982
|
+
...collectEntryNames(filesBody),
|
|
983
|
+
...collectEntryNames(generatedBody),
|
|
984
|
+
],
|
|
985
|
+
}),
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const writingRulesBody = extractSectionBody(content, 'Writing Rules');
|
|
990
|
+
const writingRuleMatches = writingRulesBody?.match(/^- /gm) ?? [];
|
|
991
|
+
if (writingRulesBody !== null) {
|
|
992
|
+
if (writingRuleMatches.length < 2 || writingRuleMatches.length > 6) {
|
|
993
|
+
issues.push('"## Writing Rules" should contain 2-6 top-level bullets');
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const placeholderLines = collectPlaceholderLines(content);
|
|
998
|
+
if (options.strictPlaceholders) {
|
|
999
|
+
for (const placeholderLine of placeholderLines) {
|
|
1000
|
+
issues.push(`placeholder text remains: ${placeholderLine.trim()}`);
|
|
1001
|
+
}
|
|
1002
|
+
} else {
|
|
1003
|
+
for (const placeholderLine of placeholderLines) {
|
|
1004
|
+
warnings.push(`placeholder text remains: ${placeholderLine.trim()}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (isExcludedAgentsDirectory(directoryPath)) {
|
|
1009
|
+
issues.push(
|
|
1010
|
+
'repo-local skill packages must not contain nested AGENTS.md files',
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return {
|
|
1015
|
+
issues,
|
|
1016
|
+
warnings,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export async function runValidateAgentsCommand(rawArgs = [], runtime = {}) {
|
|
1021
|
+
const cwd = runtime.cwd ?? process.cwd();
|
|
1022
|
+
const env = runtime.env ?? process.env;
|
|
1023
|
+
const stdout = runtime.stdout ?? process.stdout;
|
|
1024
|
+
const stderr = runtime.stderr ?? process.stderr;
|
|
1025
|
+
|
|
1026
|
+
try {
|
|
1027
|
+
const repoRoot = getRepoRoot(env, cwd);
|
|
1028
|
+
const excludedScanDirectories = getExcludedScanDirectories(repoRoot);
|
|
1029
|
+
const { debug, mode, scope, strictPlaceholders } = parseValidateArguments(
|
|
1030
|
+
rawArgs,
|
|
1031
|
+
repoRoot,
|
|
1032
|
+
);
|
|
1033
|
+
const logger = createLogger('validate-agents', {
|
|
1034
|
+
debugEnabled: debug,
|
|
1035
|
+
stderr,
|
|
1036
|
+
stdout,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
logger.debug('repo_root_resolved', { repoRoot });
|
|
1040
|
+
logger.debug('scan_exclusions_resolved', { excludedScanDirectories });
|
|
1041
|
+
logger.debug('command_options_resolved', {
|
|
1042
|
+
mode,
|
|
1043
|
+
scopePath: scope.scopePath,
|
|
1044
|
+
scopeType: scope.scopeType,
|
|
1045
|
+
strictPlaceholders,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
const scopeDirectory = getScopeDirectory(scope);
|
|
1049
|
+
if (isExcludedAgentsDirectory(scopeDirectory)) {
|
|
1050
|
+
throw new CommandError(
|
|
1051
|
+
'Do not run the AGENTS tool inside .codex skill packages. Use SKILL.md and references/ instead.',
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const { filePaths: allRepositoryPaths, source } = collectRepositoryFiles(
|
|
1056
|
+
repoRoot,
|
|
1057
|
+
excludedScanDirectories,
|
|
1058
|
+
);
|
|
1059
|
+
logger.debug('repository_inventory_collected', {
|
|
1060
|
+
pathCount: allRepositoryPaths.length,
|
|
1061
|
+
source,
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
const inventoryFiles = getInventoryFiles(
|
|
1065
|
+
allRepositoryPaths,
|
|
1066
|
+
excludedScanDirectories,
|
|
1067
|
+
);
|
|
1068
|
+
const existingAgentsDirectories =
|
|
1069
|
+
getVisibleAgentsDirectories(allRepositoryPaths);
|
|
1070
|
+
const explicitDirectories = getExplicitDirectories(scope);
|
|
1071
|
+
const requiredDirectories = getRequiredDirectories(
|
|
1072
|
+
inventoryFiles,
|
|
1073
|
+
explicitDirectories,
|
|
1074
|
+
);
|
|
1075
|
+
const directoriesToSync = getDirectoriesToSync(
|
|
1076
|
+
requiredDirectories,
|
|
1077
|
+
existingAgentsDirectories,
|
|
1078
|
+
scope,
|
|
1079
|
+
explicitDirectories,
|
|
1080
|
+
);
|
|
1081
|
+
logger.debug('scope_resolved', {
|
|
1082
|
+
directoriesToProcess: directoriesToSync,
|
|
1083
|
+
existingAgentsDirectoryCount: existingAgentsDirectories.length,
|
|
1084
|
+
explicitDirectoryCount: explicitDirectories.length,
|
|
1085
|
+
inventoryFileCount: inventoryFiles.length,
|
|
1086
|
+
requiredDirectoryCount: requiredDirectories.length,
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
const changedPaths = [];
|
|
1090
|
+
let createdCount = 0;
|
|
1091
|
+
let updatedCount = 0;
|
|
1092
|
+
|
|
1093
|
+
for (const directoryPath of directoriesToSync) {
|
|
1094
|
+
const targetAgentsPath =
|
|
1095
|
+
directoryPath === '.' ? 'AGENTS.md' : `${directoryPath}/AGENTS.md`;
|
|
1096
|
+
const inventory = getImmediateChildren(
|
|
1097
|
+
directoryPath,
|
|
1098
|
+
inventoryFiles,
|
|
1099
|
+
excludedScanDirectories,
|
|
1100
|
+
explicitDirectories,
|
|
1101
|
+
);
|
|
1102
|
+
logger.debug('directory_sync_started', {
|
|
1103
|
+
directoryCount: inventory.directories.length,
|
|
1104
|
+
fileCount: inventory.files.length,
|
|
1105
|
+
targetAgentsPath,
|
|
1106
|
+
});
|
|
1107
|
+
const result = syncAgentsFile(repoRoot, directoryPath, inventory, mode);
|
|
1108
|
+
|
|
1109
|
+
if (!result.changed) {
|
|
1110
|
+
logger.success(
|
|
1111
|
+
`${logger.style(targetAgentsPath, 'path')} is already in sync.`,
|
|
1112
|
+
);
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
changedPaths.push(path.relative(repoRoot, result.path) || 'AGENTS.md');
|
|
1117
|
+
|
|
1118
|
+
if (result.created) {
|
|
1119
|
+
createdCount += 1;
|
|
1120
|
+
logger.warn(
|
|
1121
|
+
`${mode === 'check' ? 'Would create' : 'Created'} ${logger.style(targetAgentsPath, 'path')}.`,
|
|
1122
|
+
);
|
|
1123
|
+
} else {
|
|
1124
|
+
updatedCount += 1;
|
|
1125
|
+
logger.warn(
|
|
1126
|
+
`${mode === 'check' ? 'Would update' : 'Updated'} ${logger.style(targetAgentsPath, 'path')}.`,
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const agentsPathsToValidate = directoriesToSync
|
|
1132
|
+
.map((directoryPath) =>
|
|
1133
|
+
directoryPath === '.' ? 'AGENTS.md' : `${directoryPath}/AGENTS.md`,
|
|
1134
|
+
)
|
|
1135
|
+
.filter((agentsPath) => existsSync(path.join(repoRoot, agentsPath)));
|
|
1136
|
+
logger.debug('validation_targets_resolved', {
|
|
1137
|
+
agentsFileCount: agentsPathsToValidate.length,
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
const issuesByPath = [];
|
|
1141
|
+
const warningsByPath = [];
|
|
1142
|
+
|
|
1143
|
+
for (const agentsPath of agentsPathsToValidate) {
|
|
1144
|
+
const result = validateAgentsFile(repoRoot, agentsPath, inventoryFiles, {
|
|
1145
|
+
excludedScanDirectories,
|
|
1146
|
+
explicitDirectories,
|
|
1147
|
+
strictPlaceholders,
|
|
1148
|
+
});
|
|
1149
|
+
logger.debug('validation_result_recorded', {
|
|
1150
|
+
agentsPath,
|
|
1151
|
+
issueCount: result.issues.length,
|
|
1152
|
+
warningCount: result.warnings.length,
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
if (result.issues.length > 0) {
|
|
1156
|
+
issuesByPath.push({
|
|
1157
|
+
issues: result.issues,
|
|
1158
|
+
path: agentsPath,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (result.warnings.length > 0) {
|
|
1163
|
+
warningsByPath.push({
|
|
1164
|
+
path: agentsPath,
|
|
1165
|
+
warnings: result.warnings,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (result.issues.length > 0) {
|
|
1170
|
+
logger.error(
|
|
1171
|
+
`${logger.style(agentsPath, 'path')} has ${logger.style(String(result.issues.length), 'error')} issue(s).`,
|
|
1172
|
+
);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
logger.success(`${logger.style(agentsPath, 'path')} passed validation.`);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (warningsByPath.length > 0) {
|
|
1180
|
+
logger.info('AGENTS.md placeholder warnings:');
|
|
1181
|
+
for (const entry of warningsByPath) {
|
|
1182
|
+
stdout.write(`- ${logger.style(entry.path, 'path')}\n`);
|
|
1183
|
+
for (const warning of entry.warnings) {
|
|
1184
|
+
stdout.write(` - ${warning}\n`);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (mode === 'fix') {
|
|
1190
|
+
if (changedPaths.length === 0) {
|
|
1191
|
+
logger.success('AGENTS.md files were already in sync.');
|
|
1192
|
+
} else {
|
|
1193
|
+
logger.success(
|
|
1194
|
+
`Synced AGENTS.md files. Created ${createdCount}, updated ${updatedCount}.`,
|
|
1195
|
+
);
|
|
1196
|
+
for (const changedPath of changedPaths) {
|
|
1197
|
+
stdout.write(`- ${logger.style(changedPath, 'path')}\n`);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
} else if (changedPaths.length > 0) {
|
|
1201
|
+
logger.error('AGENTS.md files are out of date:');
|
|
1202
|
+
for (const changedPath of changedPaths) {
|
|
1203
|
+
stderr.write(`- ${logger.style(changedPath, 'path')}\n`);
|
|
1204
|
+
}
|
|
1205
|
+
logger.error(
|
|
1206
|
+
'Run the bundled AGENTS tool with --fix to refresh the hierarchy.',
|
|
1207
|
+
);
|
|
1208
|
+
} else {
|
|
1209
|
+
logger.success('AGENTS.md files are in sync.');
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
if (issuesByPath.length > 0) {
|
|
1213
|
+
logger.error('AGENTS.md validation failed:');
|
|
1214
|
+
for (const entry of issuesByPath) {
|
|
1215
|
+
stderr.write(`- ${logger.style(entry.path, 'path')}\n`);
|
|
1216
|
+
for (const issue of entry.issues) {
|
|
1217
|
+
stderr.write(` - ${issue}\n`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (
|
|
1223
|
+
issuesByPath.length > 0 ||
|
|
1224
|
+
(mode === 'check' && changedPaths.length > 0)
|
|
1225
|
+
) {
|
|
1226
|
+
return 1;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
logger.success('AGENTS.md validation passed.');
|
|
1230
|
+
return 0;
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
const logger = createLogger('validate-agents', {
|
|
1233
|
+
debugEnabled: rawArgs.includes('--debug'),
|
|
1234
|
+
stderr,
|
|
1235
|
+
stdout,
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
if (isCommandError(error)) {
|
|
1239
|
+
logger.error(error.message);
|
|
1240
|
+
return error.exitCode;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
throw error;
|
|
1244
|
+
}
|
|
1245
|
+
}
|