@polarpoint/agentsmd-validator 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 +77 -0
- package/bin/agentsmd-validator.js +134 -0
- package/lib/parser.js +82 -0
- package/lib/validator.js +87 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# @polarpoint/agentsmd-validator
|
|
2
|
+
|
|
3
|
+
Validates `AGENTS.md` files against a zone-structured schema. Designed for use in CI pipelines across platform engineering repos.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @polarpoint/agentsmd-validator \
|
|
9
|
+
--schema https://raw.githubusercontent.com/your-org/platform-standards/main/schema.json \
|
|
10
|
+
--file AGENTS.md
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Options
|
|
14
|
+
|
|
15
|
+
| Flag | Description |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `--file <path>` | Path to the AGENTS.md file to validate (required) |
|
|
18
|
+
| `--schema <url\|path>` | URL or local path to a schema.json (optional) |
|
|
19
|
+
| `--strict` | Treat warnings as errors (non-zero exit) |
|
|
20
|
+
|
|
21
|
+
## What it checks
|
|
22
|
+
|
|
23
|
+
1. **Zone markers present** — `<!-- zone:1:start -->` / `<!-- zone:1:end -->` exist
|
|
24
|
+
2. **Zone order** — zones appear in ascending numeric order
|
|
25
|
+
3. **Required Zone 1 sections** — `## Sync model`, `## Forbidden paths`, `## Naming conventions`, `## Running tests` all present
|
|
26
|
+
4. **Running tests is actionable** — the section contains at least one executable command (code block or inline command)
|
|
27
|
+
5. **Zone 1 hash** — if a schema is supplied, warns when Zone 1 content has drifted from the template version
|
|
28
|
+
|
|
29
|
+
## Zone structure
|
|
30
|
+
|
|
31
|
+
```markdown
|
|
32
|
+
<!-- zone:1:start -->
|
|
33
|
+
## Sync model
|
|
34
|
+
...
|
|
35
|
+
## Forbidden paths
|
|
36
|
+
...
|
|
37
|
+
## Naming conventions
|
|
38
|
+
...
|
|
39
|
+
## Running tests
|
|
40
|
+
...
|
|
41
|
+
<!-- zone:1:end -->
|
|
42
|
+
|
|
43
|
+
<!-- zone:2:start -->
|
|
44
|
+
## Team conventions
|
|
45
|
+
...
|
|
46
|
+
<!-- zone:2:end -->
|
|
47
|
+
|
|
48
|
+
<!-- zone:3:start -->
|
|
49
|
+
## Repo-specific notes
|
|
50
|
+
...
|
|
51
|
+
<!-- zone:3:end -->
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## GitHub Actions
|
|
55
|
+
|
|
56
|
+
Copy `.github/workflows/validate-agents-md.yml` into your repo, update the `--schema` URL to point at your `platform-standards` repo, and the validator runs on every PR that touches `AGENTS.md`.
|
|
57
|
+
|
|
58
|
+
## Schema format
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"templateVersion": "1.0.0",
|
|
63
|
+
"zone1Hash": "98dfe660cbdaf240",
|
|
64
|
+
"requiredSections": ["Sync model", "Forbidden paths", "Naming conventions", "Running tests"]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
After editing your template, regenerate `zone1Hash`:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
node -e "
|
|
72
|
+
const c=require('crypto'), f=require('fs');
|
|
73
|
+
const z=f.readFileSync('templates/default-agents-md.md','utf8')
|
|
74
|
+
.match(/zone:1:start[\s\S]*?zone:1:end/)[0];
|
|
75
|
+
console.log(c.createHash('sha256').update(z.replace(/\s+/g,' ').trim()).digest('hex').slice(0,16))
|
|
76
|
+
"
|
|
77
|
+
```
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
const { validate } = require('../lib/validator');
|
|
9
|
+
|
|
10
|
+
// ── CLI argument parsing ──────────────────────────────────────────────────────
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
let schemaArg = null;
|
|
13
|
+
let fileArg = null;
|
|
14
|
+
let strict = false;
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
if (args[i] === '--schema' && args[i + 1]) { schemaArg = args[++i]; continue; }
|
|
18
|
+
if (args[i] === '--file' && args[i + 1]) { fileArg = args[++i]; continue; }
|
|
19
|
+
if (args[i] === '--strict') { strict = true; continue; }
|
|
20
|
+
if (args[i] === '--version' || args[i] === '-v') {
|
|
21
|
+
const pkg = require('../package.json');
|
|
22
|
+
console.log(pkg.version);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
if (args[i] === '--help' || args[i] === '-h') {
|
|
26
|
+
console.log(`
|
|
27
|
+
Usage: agentsmd-validator --file <path> [--schema <url|path>] [--strict]
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--file <path> Path to AGENTS.md file to validate (required)
|
|
31
|
+
--schema <url> URL or path to schema.json for drift detection (optional)
|
|
32
|
+
--strict Treat warnings as errors (non-zero exit code)
|
|
33
|
+
--version, -v Print version
|
|
34
|
+
--help, -h Print this help
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
npx @polarpoint/agentsmd-validator --file AGENTS.md
|
|
38
|
+
npx @polarpoint/agentsmd-validator \\
|
|
39
|
+
--file AGENTS.md \\
|
|
40
|
+
--schema https://raw.githubusercontent.com/your-org/platform-standards/main/schema.json
|
|
41
|
+
`);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!fileArg) {
|
|
47
|
+
console.error('Error: --file is required\n');
|
|
48
|
+
console.error('Usage: agentsmd-validator --file <path> [--schema <url|path>] [--strict]');
|
|
49
|
+
console.error(' agentsmd-validator --help');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
54
|
+
function fetchUrl(url) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const client = url.startsWith('https') ? https : http;
|
|
57
|
+
client.get(url, res => {
|
|
58
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
59
|
+
return fetchUrl(res.headers.location).then(resolve).catch(reject);
|
|
60
|
+
}
|
|
61
|
+
if (res.statusCode !== 200) {
|
|
62
|
+
return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
|
63
|
+
}
|
|
64
|
+
let data = '';
|
|
65
|
+
res.on('data', chunk => { data += chunk; });
|
|
66
|
+
res.on('end', () => resolve(data));
|
|
67
|
+
}).on('error', reject);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loadSchema(schemaArg) {
|
|
72
|
+
if (!schemaArg) return Promise.resolve(null);
|
|
73
|
+
if (schemaArg.startsWith('http://') || schemaArg.startsWith('https://')) {
|
|
74
|
+
return fetchUrl(schemaArg).then(JSON.parse).catch(err => {
|
|
75
|
+
console.warn(` ⚠ Could not fetch schema from ${schemaArg}: ${err.message}`);
|
|
76
|
+
return null;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return Promise.resolve(JSON.parse(fs.readFileSync(path.resolve(schemaArg), 'utf8')));
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.warn(` ⚠ Could not read schema file ${schemaArg}: ${err.message}`);
|
|
83
|
+
return Promise.resolve(null);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
88
|
+
async function main() {
|
|
89
|
+
const filePath = path.resolve(fileArg);
|
|
90
|
+
if (!fs.existsSync(filePath)) {
|
|
91
|
+
console.error(` ✗ File not found: ${filePath}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
96
|
+
const schemaData = await loadSchema(schemaArg);
|
|
97
|
+
|
|
98
|
+
console.log(`\nValidating ${path.basename(filePath)}\n${'─'.repeat(50)}`);
|
|
99
|
+
|
|
100
|
+
const { errors, warnings, zone1Hash } = validate(content, schemaData);
|
|
101
|
+
|
|
102
|
+
if (zone1Hash) {
|
|
103
|
+
console.log(` Zone 1 hash : ${zone1Hash}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (warnings.length) {
|
|
107
|
+
console.log('');
|
|
108
|
+
warnings.forEach(w => console.log(` ⚠ ${w}`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (errors.length) {
|
|
112
|
+
console.log('');
|
|
113
|
+
errors.forEach(e => console.error(` ✗ ${e}`));
|
|
114
|
+
console.log(`\n${'─'.repeat(50)}`);
|
|
115
|
+
console.log(` FAILED ${errors.length} error(s), ${warnings.length} warning(s)\n`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`\n${'─'.repeat(50)}`);
|
|
120
|
+
if (warnings.length) {
|
|
121
|
+
console.log(` PASSED with ${warnings.length} warning(s)\n`);
|
|
122
|
+
if (strict) {
|
|
123
|
+
console.log(' (--strict mode: warnings treated as errors)');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
console.log(' PASSED\n');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
main().catch(err => {
|
|
132
|
+
console.error(` ✗ Unexpected error: ${err.message}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
});
|
package/lib/parser.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const ZONE_START = /<!--\s*zone:(\d+):start\s*-->/;
|
|
6
|
+
const ZONE_END = /<!--\s*zone:(\d+):end\s*-->/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse an AGENTS.md file into its zone segments.
|
|
10
|
+
* Returns { zones: { 1: string, 2: string, 3: string }, raw: string }
|
|
11
|
+
*/
|
|
12
|
+
function parseZones(content) {
|
|
13
|
+
const lines = content.split('\n');
|
|
14
|
+
const zones = {};
|
|
15
|
+
let currentZone = null;
|
|
16
|
+
let buffer = [];
|
|
17
|
+
const order = []; // track order zones were opened
|
|
18
|
+
|
|
19
|
+
for (const line of lines) {
|
|
20
|
+
const startMatch = line.match(ZONE_START);
|
|
21
|
+
const endMatch = line.match(ZONE_END);
|
|
22
|
+
|
|
23
|
+
if (startMatch) {
|
|
24
|
+
currentZone = parseInt(startMatch[1], 10);
|
|
25
|
+
order.push(currentZone);
|
|
26
|
+
buffer = [];
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (endMatch) {
|
|
31
|
+
const z = parseInt(endMatch[1], 10);
|
|
32
|
+
if (currentZone === z) {
|
|
33
|
+
zones[z] = buffer.join('\n').trim();
|
|
34
|
+
currentZone = null;
|
|
35
|
+
buffer = [];
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (currentZone !== null) {
|
|
41
|
+
buffer.push(line);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { zones, order, raw: content };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute a stable SHA-256 hash of zone content (normalised whitespace).
|
|
50
|
+
*/
|
|
51
|
+
function hashZone(content) {
|
|
52
|
+
const normalised = content.replace(/\s+/g, ' ').trim();
|
|
53
|
+
return crypto.createHash('sha256').update(normalised).digest('hex').slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract all headings (## level) from a zone string.
|
|
58
|
+
*/
|
|
59
|
+
function headingsInZone(zoneContent) {
|
|
60
|
+
return (zoneContent.match(/^##\s+.+/gm) || []).map(h => h.replace(/^##\s+/, '').trim());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect whether a block of text contains at least one shell-executable command.
|
|
65
|
+
* Heuristic: a line inside a fenced code block, or a line starting with common
|
|
66
|
+
* shell tokens (make, npm, yarn, pnpm, go, cargo, pytest, mvn, gradle, ./...).
|
|
67
|
+
*/
|
|
68
|
+
function hasExecutableCommand(zoneContent) {
|
|
69
|
+
const codeBlocks = [...zoneContent.matchAll(/```[\s\S]*?```/g)];
|
|
70
|
+
if (codeBlocks.length > 0) {
|
|
71
|
+
for (const block of codeBlocks) {
|
|
72
|
+
const inner = block[0].replace(/^```[^\n]*\n/, '').replace(/```$/, '');
|
|
73
|
+
if (/\S/.test(inner)) return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// inline code that looks like a command
|
|
77
|
+
const inlineCode = [...zoneContent.matchAll(/`([^`]+)`/g)].map(m => m[1]);
|
|
78
|
+
const cmdPattern = /^(make|npm|yarn|pnpm|go\s|cargo|pytest|mvn|gradle|\.\/|python|node|bash|sh\s|docker|kubectl)/;
|
|
79
|
+
return inlineCode.some(c => cmdPattern.test(c.trim()));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { parseZones, hashZone, headingsInZone, hasExecutableCommand };
|
package/lib/validator.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseZones, hashZone, headingsInZone, hasExecutableCommand } = require('./parser');
|
|
4
|
+
|
|
5
|
+
const REQUIRED_ZONE1_SECTIONS = [
|
|
6
|
+
'Sync model',
|
|
7
|
+
'Forbidden paths',
|
|
8
|
+
'Naming conventions',
|
|
9
|
+
'Running tests',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run all checks against the parsed content.
|
|
14
|
+
* Returns { errors: string[], warnings: string[], zone1Hash: string }
|
|
15
|
+
*/
|
|
16
|
+
function validate(content, schemaData) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
const warnings = [];
|
|
19
|
+
|
|
20
|
+
const { zones, order } = parseZones(content);
|
|
21
|
+
|
|
22
|
+
// ── 1. Zone markers present ───────────────────────────────────────────────
|
|
23
|
+
if (!zones[1]) {
|
|
24
|
+
errors.push('Zone 1 is missing. Add <!-- zone:1:start --> and <!-- zone:1:end --> markers.');
|
|
25
|
+
}
|
|
26
|
+
if (!zones[2]) {
|
|
27
|
+
warnings.push('Zone 2 markers not found. Guarded section is optional but recommended.');
|
|
28
|
+
}
|
|
29
|
+
if (!zones[3]) {
|
|
30
|
+
warnings.push('Zone 3 markers not found. Free section is optional but recommended.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── 2. Zone order ─────────────────────────────────────────────────────────
|
|
34
|
+
if (order.length > 1) {
|
|
35
|
+
for (let i = 1; i < order.length; i++) {
|
|
36
|
+
if (order[i] < order[i - 1]) {
|
|
37
|
+
errors.push(`Zone markers are out of order: zone ${order[i]} appears after zone ${order[i - 1]}.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── 3. Required sections inside Zone 1 ───────────────────────────────────
|
|
43
|
+
if (zones[1]) {
|
|
44
|
+
const headings = headingsInZone(zones[1]);
|
|
45
|
+
for (const section of REQUIRED_ZONE1_SECTIONS) {
|
|
46
|
+
if (!headings.some(h => h.toLowerCase().includes(section.toLowerCase()))) {
|
|
47
|
+
errors.push(`Required section "## ${section}" is missing from Zone 1.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── 4. Running tests must contain an executable command ────────────────
|
|
52
|
+
const testsIdx = headings.findIndex(h => h.toLowerCase().includes('running tests'));
|
|
53
|
+
if (testsIdx !== -1) {
|
|
54
|
+
// extract just the Running tests sub-section
|
|
55
|
+
const z1Lines = zones[1].split('\n');
|
|
56
|
+
let inTests = false;
|
|
57
|
+
let testsBuffer = [];
|
|
58
|
+
for (const line of z1Lines) {
|
|
59
|
+
if (/^##\s+Running tests/i.test(line)) { inTests = true; continue; }
|
|
60
|
+
if (inTests && /^##\s+/.test(line)) break;
|
|
61
|
+
if (inTests) testsBuffer.push(line);
|
|
62
|
+
}
|
|
63
|
+
const testsContent = testsBuffer.join('\n');
|
|
64
|
+
if (!hasExecutableCommand(testsContent)) {
|
|
65
|
+
errors.push('"## Running tests" in Zone 1 must contain at least one executable command (code block or inline command).');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 5. Zone 1 hash drift check ────────────────────────────────────────
|
|
70
|
+
const zone1Hash = hashZone(zones[1]);
|
|
71
|
+
if (schemaData && schemaData.zone1Hash) {
|
|
72
|
+
if (zone1Hash !== schemaData.zone1Hash) {
|
|
73
|
+
warnings.push(
|
|
74
|
+
`Zone 1 content hash (${zone1Hash}) does not match template version ` +
|
|
75
|
+
`${schemaData.templateVersion || 'unknown'} (${schemaData.zone1Hash}). ` +
|
|
76
|
+
`Run the sync engine or update Zone 1 from platform-standards.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { errors, warnings, zone1Hash };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { errors, warnings, zone1Hash: null };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { validate };
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@polarpoint/agentsmd-validator",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Validate AGENTS.md files against a zone-structured schema",
|
|
5
|
+
"main": "lib/validator.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agentsmd-validator": "./bin/agentsmd-validator.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"prepublishOnly": "node bin/agentsmd-validator.js --file bin/agentsmd-validator.js 2>/dev/null || true"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"agents",
|
|
19
|
+
"ai",
|
|
20
|
+
"validation",
|
|
21
|
+
"platform-engineering",
|
|
22
|
+
"gitops",
|
|
23
|
+
"agentsmd"
|
|
24
|
+
],
|
|
25
|
+
"author": "Polarpoint <hello@polarpoint.io>",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org/"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/polarpoint-io/ai-capabilities.git",
|
|
37
|
+
"directory": "agentsmd-validator"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/polarpoint-io/ai-capabilities/tree/main/agentsmd-validator#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/polarpoint-io/ai-capabilities/issues"
|
|
42
|
+
}
|
|
43
|
+
}
|