@yegor256/dogent 0.0.1
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/LICENSE.txt +21 -0
- package/README.md +128 -0
- package/package.json +44 -0
- package/src/document.js +30 -0
- package/src/dogent.js +30 -0
- package/src/fragments/bullets.js +27 -0
- package/src/fragments/header.js +25 -0
- package/src/fragments/prose.js +24 -0
- package/src/fragments/snippet.js +24 -0
- package/src/markdown.js +89 -0
- package/src/region.js +38 -0
- package/src/report.js +40 -0
- package/src/rules/command.js +56 -0
- package/src/rules/grouped.js +46 -0
- package/src/rules/index.js +20 -0
- package/src/rules/line-length.js +44 -0
- package/src/rules/no-articles.js +47 -0
- package/src/rules/short-sections.js +48 -0
- package/src/violation.js +39 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
(The MIT License)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yegor Bugayenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the 'Software'), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Dogmatic Linter for Agent Skills and Manifestos
|
|
2
|
+
|
|
3
|
+
[](https://github.com/yegor256/dogent/actions/workflows/dogent.yml)
|
|
4
|
+
[](https://www.0pdd.com/p?name=yegor256/dogent)
|
|
5
|
+
[](https://github.com/yegor256/dogent/blob/master/LICENSE.txt)
|
|
6
|
+
[](https://badge.fury.io/js/@yegor256%2Fdogent)
|
|
7
|
+
|
|
8
|
+
A strict, opinionated linter for agentic manifesto files
|
|
9
|
+
such as `SKILL.md`, `CLAUDE.md`, and `AGENTS.md`.
|
|
10
|
+
|
|
11
|
+
These files instruct AI agents.
|
|
12
|
+
Vague, bloated, or ambiguous instructions make agents behave unpredictably.
|
|
13
|
+
`dogent` enforces a clear, command-style discipline
|
|
14
|
+
so every line earns its place.
|
|
15
|
+
|
|
16
|
+
We respect [agent-sh/agnix](https://github.com/agent-sh/agnix)
|
|
17
|
+
as a prototype of this idea.
|
|
18
|
+
`dogent` goes further: it is stricter, more opinionated,
|
|
19
|
+
and aims for extreme quality with no compromise.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Run it on any manifesto file, no installation required:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @yegor256/dogent CLAUDE.md
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Lint several files at once:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx @yegor256/dogent SKILL.md CLAUDE.md AGENTS.md
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Sample output:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
CLAUDE.md
|
|
39
|
+
12: line exceeds 80 symbols
|
|
40
|
+
18: not an instruction, sounds like description
|
|
41
|
+
24: article "the" detected, remove noise
|
|
42
|
+
31: section name too long, use 1-3 words
|
|
43
|
+
|
|
44
|
+
4 problems found, exit code 1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The command exits with a non-zero status when problems are found,
|
|
48
|
+
so it plugs directly into CI and pre-commit hooks.
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
`dogent` checks that every manifesto obeys these rules:
|
|
53
|
+
|
|
54
|
+
- Every line must be an instruction.
|
|
55
|
+
- Instructions must be grouped in sections.
|
|
56
|
+
- Section names must be short, 1-3 words.
|
|
57
|
+
- Every line must be no longer than 80 symbols.
|
|
58
|
+
- Every line must sound like a command.
|
|
59
|
+
- No articles, no noise, no bloated text.
|
|
60
|
+
- Simple grammar, no ambiguity.
|
|
61
|
+
|
|
62
|
+
## AI verification
|
|
63
|
+
|
|
64
|
+
`dogent` works standalone by default,
|
|
65
|
+
using fast deterministic checks with no network access.
|
|
66
|
+
When `OPENAI_API_KEY` or `CLAUDE_TOKEN` is present in the environment,
|
|
67
|
+
it additionally uses AI to verify the text for ambiguity,
|
|
68
|
+
weak phrasing, and instructions that only pretend to be commands:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export CLAUDE_TOKEN=...
|
|
72
|
+
npx @yegor256/dogent CLAUDE.md
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## GitHub Actions
|
|
76
|
+
|
|
77
|
+
Because `dogent` runs through `npx`, no extra action is needed.
|
|
78
|
+
Add a single step to any workflow to lint your manifestos on every push:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
name: dogent
|
|
82
|
+
on: [push, pull_request]
|
|
83
|
+
jobs:
|
|
84
|
+
lint:
|
|
85
|
+
runs-on: ubuntu-latest
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@v4
|
|
88
|
+
- uses: actions/setup-node@v4
|
|
89
|
+
with:
|
|
90
|
+
node-version: 20
|
|
91
|
+
- run: npx @yegor256/dogent CLAUDE.md SKILL.md
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The job fails when `dogent` finds problems,
|
|
95
|
+
blocking the merge until the manifestos are clean.
|
|
96
|
+
To enable AI verification in CI, expose a token as a secret:
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
- run: npx @yegor256/dogent CLAUDE.md
|
|
100
|
+
env:
|
|
101
|
+
CLAUDE_TOKEN: ${{ secrets.CLAUDE_TOKEN }}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Pre-commit hook
|
|
105
|
+
|
|
106
|
+
Run `dogent` before every commit so broken manifestos never reach history.
|
|
107
|
+
Drop this into `.git/hooks/pre-commit` and make it executable:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
#!/bin/sh
|
|
111
|
+
npx @yegor256/dogent CLAUDE.md SKILL.md AGENTS.md
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Prefer the [pre-commit](https://pre-commit.com) framework?
|
|
115
|
+
Add `dogent` as a local hook in `.pre-commit-config.yaml`:
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
repos:
|
|
119
|
+
- repo: local
|
|
120
|
+
hooks:
|
|
121
|
+
- id: dogent
|
|
122
|
+
name: dogent
|
|
123
|
+
entry: npx @yegor256/dogent
|
|
124
|
+
language: system
|
|
125
|
+
files: '(CLAUDE|SKILL|AGENTS)\.md$'
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Either way, the commit is rejected until every flagged line is fixed.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": "Yegor Bugayenko <yegor256@gmail.com> (https://www.yegor256.com/)",
|
|
3
|
+
"bin": {
|
|
4
|
+
"dogent": "./src/dogent.js"
|
|
5
|
+
},
|
|
6
|
+
"bugs": "https://github.com/yegor256/dogent/issues",
|
|
7
|
+
"description": "A strict, opinionated linter for agentic manifesto files (SKILL.md, CLAUDE.md, AGENTS.md)",
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@eslint/js": "^9.28.0",
|
|
10
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
11
|
+
"eslint": "^9.28.0",
|
|
12
|
+
"globals": "^17.6.0",
|
|
13
|
+
"mocha": "^11.5.0"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/yegor256/dogent",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"linter",
|
|
24
|
+
"lint",
|
|
25
|
+
"markdown",
|
|
26
|
+
"claude",
|
|
27
|
+
"agents",
|
|
28
|
+
"skill",
|
|
29
|
+
"manifesto",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"main": "src/dogent.js",
|
|
34
|
+
"name": "@yegor256/dogent",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"repository": "yegor256/dogent",
|
|
39
|
+
"scripts": {
|
|
40
|
+
"lint": "eslint .",
|
|
41
|
+
"test": "mocha 'test/**/*.js' --timeout 60000"
|
|
42
|
+
},
|
|
43
|
+
"version": "0.0.1"
|
|
44
|
+
}
|
package/src/document.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Document.
|
|
10
|
+
*
|
|
11
|
+
* An entire manifesto already parsed into an ordered collection of
|
|
12
|
+
* fragments, ready to be walked by a rule that hunts for violations.
|
|
13
|
+
*/
|
|
14
|
+
class Document {
|
|
15
|
+
constructor(uri, fragments) {
|
|
16
|
+
this.address = uri;
|
|
17
|
+
this.pieces = fragments;
|
|
18
|
+
}
|
|
19
|
+
uri() {
|
|
20
|
+
return this.address;
|
|
21
|
+
}
|
|
22
|
+
fragments() {
|
|
23
|
+
return this.pieces;
|
|
24
|
+
}
|
|
25
|
+
walk(visitor) {
|
|
26
|
+
return this.pieces.reduce((all, piece) => all.concat(piece.accept(visitor)), []);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = Document;
|
package/src/dogent.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const Markdown = require('./markdown');
|
|
11
|
+
const Report = require('./report');
|
|
12
|
+
const rules = require('./rules');
|
|
13
|
+
|
|
14
|
+
const argv = process.argv.slice(2);
|
|
15
|
+
const sarif = argv.indexOf('--sarif') !== -1;
|
|
16
|
+
const files = argv.filter((arg) => arg !== '--sarif');
|
|
17
|
+
if (files.length === 0) {
|
|
18
|
+
process.stderr.write('Usage: dogent [--sarif] <file.md>...\n');
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
const found = [];
|
|
22
|
+
files.forEach((file) => {
|
|
23
|
+
const document = new Markdown(file, fs.readFileSync(file, 'utf8')).document();
|
|
24
|
+
rules().forEach((rule) => {
|
|
25
|
+
rule.violations(document).forEach((violation) => found.push(violation));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
const report = new Report('dogent', found);
|
|
29
|
+
process.stdout.write(`${sarif ? JSON.stringify(report.sarif(), null, 2) : report.text()}\n`);
|
|
30
|
+
process.exit(report.count() > 0 ? 1 : 0);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Bullets.
|
|
10
|
+
*
|
|
11
|
+
* A composite fragment holding the inner pieces of one bullet list,
|
|
12
|
+
* dispatching itself first and then every item it contains.
|
|
13
|
+
*/
|
|
14
|
+
class Bullets {
|
|
15
|
+
constructor(items, line) {
|
|
16
|
+
this.items = items;
|
|
17
|
+
this.row = line;
|
|
18
|
+
}
|
|
19
|
+
accept(visitor) {
|
|
20
|
+
return this.items.reduce(
|
|
21
|
+
(all, item) => all.concat(item.accept(visitor)),
|
|
22
|
+
[].concat(visitor.bullets(this.row))
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = Bullets;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Header.
|
|
10
|
+
*
|
|
11
|
+
* A section heading line of a manifesto, carrying its raw text,
|
|
12
|
+
* its line number, and its depth (the count of leading hashes).
|
|
13
|
+
*/
|
|
14
|
+
class Header {
|
|
15
|
+
constructor(content, line, level) {
|
|
16
|
+
this.content = content;
|
|
17
|
+
this.row = line;
|
|
18
|
+
this.depth = level;
|
|
19
|
+
}
|
|
20
|
+
accept(visitor) {
|
|
21
|
+
return visitor.header(this.content, this.row, this.depth);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = Header;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prose.
|
|
10
|
+
*
|
|
11
|
+
* A single line of English prose, one instruction of a manifesto,
|
|
12
|
+
* carrying its raw text and its line number.
|
|
13
|
+
*/
|
|
14
|
+
class Prose {
|
|
15
|
+
constructor(content, line) {
|
|
16
|
+
this.content = content;
|
|
17
|
+
this.row = line;
|
|
18
|
+
}
|
|
19
|
+
accept(visitor) {
|
|
20
|
+
return visitor.prose(this.content, this.row);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = Prose;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Snippet.
|
|
10
|
+
*
|
|
11
|
+
* A fenced code block taken as bulk, carrying the whole block as raw
|
|
12
|
+
* text and the line number where the fence opens.
|
|
13
|
+
*/
|
|
14
|
+
class Snippet {
|
|
15
|
+
constructor(content, line) {
|
|
16
|
+
this.content = content;
|
|
17
|
+
this.row = line;
|
|
18
|
+
}
|
|
19
|
+
accept(visitor) {
|
|
20
|
+
return visitor.snippet(this.content, this.row);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = Snippet;
|
package/src/markdown.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const Document = require('./document');
|
|
9
|
+
const Header = require('./fragments/header');
|
|
10
|
+
const Prose = require('./fragments/prose');
|
|
11
|
+
const Bullets = require('./fragments/bullets');
|
|
12
|
+
const Snippet = require('./fragments/snippet');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Markdown.
|
|
16
|
+
*
|
|
17
|
+
* The raw text of a manifesto file. Reads itself line by line, holding
|
|
18
|
+
* the context of the moment (inside a fence, inside a list), and emits
|
|
19
|
+
* a Document of fragments. This is a line scanner, not a grammar.
|
|
20
|
+
*
|
|
21
|
+
* @todo #1:45min Attach wrapped continuation lines to the bullet they
|
|
22
|
+
* belong to, instead of silently dropping their nested indentation
|
|
23
|
+
* context on the floor.
|
|
24
|
+
*/
|
|
25
|
+
class Markdown {
|
|
26
|
+
constructor(uri, content) {
|
|
27
|
+
this.address = uri;
|
|
28
|
+
this.content = content;
|
|
29
|
+
}
|
|
30
|
+
document() {
|
|
31
|
+
const pieces = [];
|
|
32
|
+
let fence = '';
|
|
33
|
+
let block = [];
|
|
34
|
+
let opened = 0;
|
|
35
|
+
let items = [];
|
|
36
|
+
let started = 0;
|
|
37
|
+
const flush = () => {
|
|
38
|
+
if (items.length > 0) {
|
|
39
|
+
pieces.push(new Bullets(items, started));
|
|
40
|
+
items = [];
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
this.content.split('\n').forEach((line, index) => {
|
|
44
|
+
const row = index + 1;
|
|
45
|
+
const mark = line.match(/^\s*(?<fence>```|~~~)/u);
|
|
46
|
+
if (fence !== '') {
|
|
47
|
+
block.push(line);
|
|
48
|
+
if (mark && line.trim().indexOf(fence) === 0) {
|
|
49
|
+
pieces.push(new Snippet(block.join('\n'), opened));
|
|
50
|
+
fence = '';
|
|
51
|
+
block = [];
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (mark) {
|
|
56
|
+
flush();
|
|
57
|
+
({fence} = mark.groups);
|
|
58
|
+
block = [line];
|
|
59
|
+
opened = row;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (/^#{1,6}\s+/u.test(line)) {
|
|
63
|
+
flush();
|
|
64
|
+
pieces.push(new Header(line, row, line.match(/^#+/u)[0].length));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (/^\s*(?:[-*+]|\d+\.)\s+/u.test(line)) {
|
|
68
|
+
if (items.length === 0) {
|
|
69
|
+
started = row;
|
|
70
|
+
}
|
|
71
|
+
items.push(new Prose(line, row));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (/^\s*$/u.test(line)) {
|
|
75
|
+
flush();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
flush();
|
|
79
|
+
pieces.push(new Prose(line, row));
|
|
80
|
+
});
|
|
81
|
+
flush();
|
|
82
|
+
if (fence !== '') {
|
|
83
|
+
pieces.push(new Snippet(block.join('\n'), opened));
|
|
84
|
+
}
|
|
85
|
+
return new Document(this.address, pieces);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = Markdown;
|
package/src/region.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Region.
|
|
10
|
+
*
|
|
11
|
+
* The exact place of a violation inside a file: the file itself, a
|
|
12
|
+
* one-based line, and a one-based column. Knows how to project itself
|
|
13
|
+
* into a SARIF physical location.
|
|
14
|
+
*/
|
|
15
|
+
class Region {
|
|
16
|
+
constructor(uri, line, column) {
|
|
17
|
+
this.address = uri;
|
|
18
|
+
this.row = line;
|
|
19
|
+
this.col = column;
|
|
20
|
+
}
|
|
21
|
+
uri() {
|
|
22
|
+
return this.address;
|
|
23
|
+
}
|
|
24
|
+
line() {
|
|
25
|
+
return this.row;
|
|
26
|
+
}
|
|
27
|
+
column() {
|
|
28
|
+
return this.col;
|
|
29
|
+
}
|
|
30
|
+
sarif() {
|
|
31
|
+
return {
|
|
32
|
+
artifactLocation: {uri: this.address},
|
|
33
|
+
region: {startLine: this.row, startColumn: this.col}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = Region;
|
package/src/report.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Report.
|
|
10
|
+
*
|
|
11
|
+
* The whole verdict of a run: the tool that produced it and every
|
|
12
|
+
* violation it gathered. Renders itself for humans or as a SARIF log.
|
|
13
|
+
*/
|
|
14
|
+
class Report {
|
|
15
|
+
constructor(tool, violations) {
|
|
16
|
+
this.tool = tool;
|
|
17
|
+
this.bag = violations;
|
|
18
|
+
}
|
|
19
|
+
count() {
|
|
20
|
+
return this.bag.length;
|
|
21
|
+
}
|
|
22
|
+
text() {
|
|
23
|
+
return this.bag
|
|
24
|
+
.map((violation) => violation.text())
|
|
25
|
+
.concat(`${this.bag.length} problems found`)
|
|
26
|
+
.join('\n');
|
|
27
|
+
}
|
|
28
|
+
sarif() {
|
|
29
|
+
return {
|
|
30
|
+
version: '2.1.0',
|
|
31
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
32
|
+
runs: [{
|
|
33
|
+
tool: {driver: {name: this.tool, rules: []}},
|
|
34
|
+
results: this.bag.map((violation) => violation.sarif())
|
|
35
|
+
}]
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = Report;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const Violation = require('../violation');
|
|
9
|
+
const Region = require('../region');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Command.
|
|
13
|
+
*
|
|
14
|
+
* Demands that every instruction sound like a command. A standalone
|
|
15
|
+
* checker can only guess: it flags lines that open with a pronoun or
|
|
16
|
+
* end with a question mark, both signs of description, not order.
|
|
17
|
+
*
|
|
18
|
+
* @todo #1:90min Replace this heuristic with a real imperative-mood check
|
|
19
|
+
* driven by an AI oracle when a token is present in the environment.
|
|
20
|
+
*/
|
|
21
|
+
class Command {
|
|
22
|
+
constructor() {
|
|
23
|
+
this.id = 'command';
|
|
24
|
+
}
|
|
25
|
+
violations(document) {
|
|
26
|
+
const uri = document.uri();
|
|
27
|
+
return document.walk({
|
|
28
|
+
header: () => [],
|
|
29
|
+
prose: (text, line) => this.judge(text, line, uri),
|
|
30
|
+
snippet: () => [],
|
|
31
|
+
bullets: () => []
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
judge(text, line, uri) {
|
|
35
|
+
const clean = text.replace(/^\s*(?:[-*+]|\d+\.)\s+/u, '').trim();
|
|
36
|
+
if (clean === '') {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const first = clean
|
|
40
|
+
.split(/\s+/u)[0]
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[^a-z]/gu, '');
|
|
43
|
+
const weak = /^(?:i|you|we|they|he|she|it|this|that|these|those|there|here)$/u;
|
|
44
|
+
if (!weak.test(first) && clean.slice(-1) !== '?') {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return [new Violation(
|
|
48
|
+
this.id,
|
|
49
|
+
'warning',
|
|
50
|
+
'line must sound like a command',
|
|
51
|
+
new Region(uri, line, 1)
|
|
52
|
+
)];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = Command;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const Violation = require('../violation');
|
|
9
|
+
const Region = require('../region');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Grouped.
|
|
13
|
+
*
|
|
14
|
+
* Demands that every instruction live under a section. Any prose that
|
|
15
|
+
* appears before the first heading is loose and therefore a violation.
|
|
16
|
+
*/
|
|
17
|
+
class Grouped {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.id = 'grouped';
|
|
20
|
+
}
|
|
21
|
+
violations(document) {
|
|
22
|
+
const uri = document.uri();
|
|
23
|
+
const marks = document.walk({
|
|
24
|
+
header: (text, line) => [{header: true, line}],
|
|
25
|
+
prose: (text, line) => [{header: false, line}],
|
|
26
|
+
snippet: () => [],
|
|
27
|
+
bullets: () => []
|
|
28
|
+
});
|
|
29
|
+
let first = Infinity;
|
|
30
|
+
marks.forEach((mark) => {
|
|
31
|
+
if (mark.header && mark.line < first) {
|
|
32
|
+
first = mark.line;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
return marks
|
|
36
|
+
.filter((mark) => !mark.header && mark.line < first)
|
|
37
|
+
.map((mark) => new Violation(
|
|
38
|
+
this.id,
|
|
39
|
+
'error',
|
|
40
|
+
'instruction not grouped under a section',
|
|
41
|
+
new Region(uri, mark.line, 1)
|
|
42
|
+
));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = Grouped;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const LineLength = require('./line-length');
|
|
9
|
+
const ShortSections = require('./short-sections');
|
|
10
|
+
const Grouped = require('./grouped');
|
|
11
|
+
const NoArticles = require('./no-articles');
|
|
12
|
+
const Command = require('./command');
|
|
13
|
+
|
|
14
|
+
module.exports = () => [
|
|
15
|
+
new Grouped(),
|
|
16
|
+
new ShortSections(),
|
|
17
|
+
new LineLength(80),
|
|
18
|
+
new NoArticles(),
|
|
19
|
+
new Command()
|
|
20
|
+
];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const Violation = require('../violation');
|
|
9
|
+
const Region = require('../region');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* LineLength.
|
|
13
|
+
*
|
|
14
|
+
* Demands that every instruction and every heading stay within a
|
|
15
|
+
* maximum width. Code snippets are exempt, since code is not prose.
|
|
16
|
+
*/
|
|
17
|
+
class LineLength {
|
|
18
|
+
constructor(max) {
|
|
19
|
+
this.id = 'line-length';
|
|
20
|
+
this.max = max;
|
|
21
|
+
}
|
|
22
|
+
violations(document) {
|
|
23
|
+
const uri = document.uri();
|
|
24
|
+
return document.walk({
|
|
25
|
+
header: (text, line) => this.over(text, line, uri),
|
|
26
|
+
prose: (text, line) => this.over(text, line, uri),
|
|
27
|
+
snippet: () => [],
|
|
28
|
+
bullets: () => []
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
over(text, line, uri) {
|
|
32
|
+
if (text.length <= this.max) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return [new Violation(
|
|
36
|
+
this.id,
|
|
37
|
+
'error',
|
|
38
|
+
`line exceeds ${this.max} symbols, has ${text.length}`,
|
|
39
|
+
new Region(uri, line, this.max + 1)
|
|
40
|
+
)];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = LineLength;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const Violation = require('../violation');
|
|
9
|
+
const Region = require('../region');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* NoArticles.
|
|
13
|
+
*
|
|
14
|
+
* Demands that instructions carry no articles, the cheapest kind of
|
|
15
|
+
* noise. Flags every standalone "a", "an", and "the".
|
|
16
|
+
*/
|
|
17
|
+
class NoArticles {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.id = 'no-articles';
|
|
20
|
+
}
|
|
21
|
+
violations(document) {
|
|
22
|
+
const uri = document.uri();
|
|
23
|
+
return document.walk({
|
|
24
|
+
header: () => [],
|
|
25
|
+
prose: (text, line) => this.scan(text, line, uri),
|
|
26
|
+
snippet: () => [],
|
|
27
|
+
bullets: () => []
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
scan(text, line, uri) {
|
|
31
|
+
const found = [];
|
|
32
|
+
const regex = /\b(?:a|an|the)\b/giu;
|
|
33
|
+
let hit = regex.exec(text);
|
|
34
|
+
while (hit !== null) {
|
|
35
|
+
found.push(new Violation(
|
|
36
|
+
this.id,
|
|
37
|
+
'error',
|
|
38
|
+
`article "${hit[0]}" must be removed`,
|
|
39
|
+
new Region(uri, line, hit.index + 1)
|
|
40
|
+
));
|
|
41
|
+
hit = regex.exec(text);
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = NoArticles;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const Violation = require('../violation');
|
|
9
|
+
const Region = require('../region');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ShortSections.
|
|
13
|
+
*
|
|
14
|
+
* Demands that every section name be a short label of one to three
|
|
15
|
+
* words, so the manifesto reads as a map and not as prose.
|
|
16
|
+
*/
|
|
17
|
+
class ShortSections {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.id = 'short-sections';
|
|
20
|
+
}
|
|
21
|
+
violations(document) {
|
|
22
|
+
const uri = document.uri();
|
|
23
|
+
return document.walk({
|
|
24
|
+
header: (text, line) => this.named(text, line, uri),
|
|
25
|
+
prose: () => [],
|
|
26
|
+
snippet: () => [],
|
|
27
|
+
bullets: () => []
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
named(text, line, uri) {
|
|
31
|
+
const words = text
|
|
32
|
+
.replace(/^#{1,6}\s+/u, '')
|
|
33
|
+
.trim()
|
|
34
|
+
.split(/\s+/u)
|
|
35
|
+
.filter((word) => word !== '');
|
|
36
|
+
if (words.length >= 1 && words.length <= 3) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return [new Violation(
|
|
40
|
+
this.id,
|
|
41
|
+
'error',
|
|
42
|
+
`section name must be 1-3 words, has ${words.length}`,
|
|
43
|
+
new Region(uri, line, 1)
|
|
44
|
+
)];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = ShortSections;
|
package/src/violation.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Violation.
|
|
10
|
+
*
|
|
11
|
+
* One breach of one rule at one region of a manifesto. Renders itself
|
|
12
|
+
* either as a terse human line or as a SARIF result.
|
|
13
|
+
*/
|
|
14
|
+
class Violation {
|
|
15
|
+
constructor(rule, level, message, region) {
|
|
16
|
+
this.rule = rule;
|
|
17
|
+
this.level = level;
|
|
18
|
+
this.message = message;
|
|
19
|
+
this.spot = region;
|
|
20
|
+
}
|
|
21
|
+
text() {
|
|
22
|
+
return [
|
|
23
|
+
`${this.spot.uri()}:${this.spot.line()}:${this.spot.column()}`,
|
|
24
|
+
this.level,
|
|
25
|
+
this.rule,
|
|
26
|
+
this.message
|
|
27
|
+
].join(' ');
|
|
28
|
+
}
|
|
29
|
+
sarif() {
|
|
30
|
+
return {
|
|
31
|
+
ruleId: this.rule,
|
|
32
|
+
level: this.level,
|
|
33
|
+
message: {text: this.message},
|
|
34
|
+
locations: [{physicalLocation: this.spot.sarif()}]
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = Violation;
|