@yegor256/dogent 0.1.0 → 0.3.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 CHANGED
@@ -23,7 +23,7 @@ We respect [agent-sh/agnix](https://github.com/agent-sh/agnix)
23
23
  Run it on any manifesto file, no installation required:
24
24
 
25
25
  ```bash
26
- npx @yegor256/dogent@0.0.1 CLAUDE.md
26
+ npx @yegor256/dogent@0.1.0 CLAUDE.md
27
27
  ```
28
28
 
29
29
  Lint several files at once:
@@ -32,6 +32,13 @@ Lint several files at once:
32
32
  npx @yegor256/dogent SKILL.md CLAUDE.md AGENTS.md
33
33
  ```
34
34
 
35
+ Point it at a directory to lint the default manifestos it holds
36
+ (`AGENTS.md`, `CLAUDE.md`, `SKILL.md`, `SKILLS.md`):
37
+
38
+ ```bash
39
+ npx @yegor256/dogent .
40
+ ```
41
+
35
42
  Sample output:
36
43
 
37
44
  ```text
@@ -58,6 +65,8 @@ The command exits with a non-zero status when problems are found,
58
65
  - Every line must sound like a command.
59
66
  - No articles, no noise, no bloated text.
60
67
  - Simple grammar, no ambiguity.
68
+ - `SKILL.md` must open with valid frontmatter.
69
+ - Frontmatter must declare only allowed keys.
61
70
 
62
71
  ## AI verification
63
72
 
@@ -117,7 +126,7 @@ Reference `dogent` as a remote hook in `.pre-commit-config.yaml`:
117
126
  ```yaml
118
127
  repos:
119
128
  - repo: https://github.com/yegor256/dogent
120
- rev: 0.0.1
129
+ rev: 0.1.0
121
130
  hooks:
122
131
  - id: dogent
123
132
  ```
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  "lint": "eslint .",
41
41
  "test": "mocha 'test/**/*.js' --timeout 60000"
42
42
  },
43
- "version": "0.1.0"
43
+ "version": "0.3.0"
44
44
  }
package/src/dogent.js CHANGED
@@ -9,17 +9,18 @@
9
9
  const fs = require('fs');
10
10
  const Markdown = require('./markdown');
11
11
  const Report = require('./report');
12
+ const Sources = require('./sources');
12
13
  const rules = require('./rules');
13
14
 
14
15
  const argv = process.argv.slice(2);
15
16
  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');
17
+ const paths = argv.filter((arg) => arg !== '--sarif');
18
+ if (paths.length === 0) {
19
+ process.stderr.write('Usage: dogent [--sarif] <file.md|dir>...\n');
19
20
  process.exit(2);
20
21
  }
21
22
  const found = [];
22
- files.forEach((file) => {
23
+ new Sources(paths).files().forEach((file) => {
23
24
  const document = new Markdown(file, fs.readFileSync(file, 'utf8')).document();
24
25
  rules().forEach((rule) => {
25
26
  rule.violations(document).forEach((violation) => found.push(violation));
@@ -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
+ * Frontmatter.
10
+ *
11
+ * The leading YAML block of a manifesto, already parsed into an ordered
12
+ * list of key pairs, carrying the line where the opening fence sits.
13
+ * Stands apart from prose so no prose rule ever sees its keys.
14
+ */
15
+ class Frontmatter {
16
+ constructor(pairs, line) {
17
+ this.keys = pairs;
18
+ this.row = line;
19
+ }
20
+ accept(visitor) {
21
+ return visitor.frontmatter(this.keys, this.row);
22
+ }
23
+ }
24
+
25
+ module.exports = Frontmatter;
package/src/markdown.js CHANGED
@@ -10,6 +10,8 @@ const Header = require('./fragments/header');
10
10
  const Prose = require('./fragments/prose');
11
11
  const Bullets = require('./fragments/bullets');
12
12
  const Snippet = require('./fragments/snippet');
13
+ const Frontmatter = require('./fragments/frontmatter');
14
+ const Yaml = require('./yaml');
13
15
 
14
16
  /**
15
17
  * Markdown.
@@ -40,7 +42,21 @@ class Markdown {
40
42
  items = [];
41
43
  }
42
44
  };
43
- this.content.split('\n').forEach((line, index) => {
45
+ const lines = this.content.split('\n');
46
+ let skip = 0;
47
+ if (lines[0].trim() === '---') {
48
+ const rest = lines.slice(1);
49
+ const close = rest.findIndex((line) => line.trim() === '---');
50
+ if (close !== -1) {
51
+ const front = rest.slice(0, close).join('\n');
52
+ pieces.push(new Frontmatter(new Yaml(front, 2).pairs(), 1));
53
+ skip = close + 2;
54
+ }
55
+ }
56
+ lines.forEach((line, index) => {
57
+ if (index < skip) {
58
+ return;
59
+ }
44
60
  const row = index + 1;
45
61
  const mark = line.match(/^\s*(?<fence>```|~~~)/u);
46
62
  if (fence !== '') {
@@ -28,7 +28,8 @@ class Command {
28
28
  header: () => [],
29
29
  prose: (text, line) => this.judge(text, line, uri),
30
30
  snippet: () => [],
31
- bullets: () => []
31
+ bullets: () => [],
32
+ frontmatter: () => []
32
33
  });
33
34
  }
34
35
  judge(text, line, uri) {
@@ -0,0 +1,70 @@
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
+ * Frontmatter.
13
+ *
14
+ * Demands that a skill file open with a YAML frontmatter block, declare
15
+ * every required key, and carry no key outside the allowed set. The
16
+ * block is mandatory for the named file and ignored for every other.
17
+ */
18
+ class Frontmatter {
19
+ constructor(name, required, allowed) {
20
+ this.id = 'frontmatter';
21
+ this.name = name;
22
+ this.required = required;
23
+ this.allowed = allowed;
24
+ }
25
+ violations(document) {
26
+ const uri = document.uri();
27
+ if (uri.replace(/^.*\//u, '') !== this.name) {
28
+ return [];
29
+ }
30
+ const blocks = document.walk({
31
+ header: () => [],
32
+ prose: () => [],
33
+ snippet: () => [],
34
+ bullets: () => [],
35
+ frontmatter: (pairs) => [pairs]
36
+ });
37
+ if (blocks.length === 0) {
38
+ return [new Violation(
39
+ this.id,
40
+ 'error',
41
+ 'skill must open with frontmatter',
42
+ new Region(uri, 1, 1)
43
+ )];
44
+ }
45
+ return this.missing(blocks[0], uri).concat(this.extra(blocks[0], uri));
46
+ }
47
+ missing(pairs, uri) {
48
+ const present = pairs.map((pair) => pair.key);
49
+ return this.required
50
+ .filter((key) => !present.includes(key))
51
+ .map((key) => new Violation(
52
+ this.id,
53
+ 'error',
54
+ `frontmatter must declare "${key}"`,
55
+ new Region(uri, 1, 1)
56
+ ));
57
+ }
58
+ extra(pairs, uri) {
59
+ return pairs
60
+ .filter((pair) => !this.allowed.includes(pair.key))
61
+ .map((pair) => new Violation(
62
+ this.id,
63
+ 'error',
64
+ `frontmatter key "${pair.key}" forbidden`,
65
+ new Region(uri, pair.row, 1)
66
+ ));
67
+ }
68
+ }
69
+
70
+ module.exports = Frontmatter;
@@ -24,7 +24,8 @@ class Grouped {
24
24
  header: (text, line) => [{header: true, line}],
25
25
  prose: (text, line) => [{header: false, line}],
26
26
  snippet: () => [],
27
- bullets: () => []
27
+ bullets: () => [],
28
+ frontmatter: () => []
28
29
  });
29
30
  let first = Infinity;
30
31
  marks.forEach((mark) => {
@@ -10,11 +10,17 @@ const ShortSections = require('./short-sections');
10
10
  const Grouped = require('./grouped');
11
11
  const NoArticles = require('./no-articles');
12
12
  const Command = require('./command');
13
+ const Frontmatter = require('./frontmatter');
13
14
 
14
15
  module.exports = () => [
15
16
  new Grouped(),
16
17
  new ShortSections(),
17
18
  new LineLength(80),
18
19
  new NoArticles(),
19
- new Command()
20
+ new Command(),
21
+ new Frontmatter(
22
+ 'SKILL.md',
23
+ ['name', 'description'],
24
+ ['name', 'description', 'license', 'allowed-tools']
25
+ )
20
26
  ];
@@ -25,7 +25,8 @@ class LineLength {
25
25
  header: (text, line) => this.over(text, line, uri),
26
26
  prose: (text, line) => this.over(text, line, uri),
27
27
  snippet: () => [],
28
- bullets: () => []
28
+ bullets: () => [],
29
+ frontmatter: () => []
29
30
  });
30
31
  }
31
32
  over(text, line, uri) {
@@ -24,7 +24,8 @@ class NoArticles {
24
24
  header: () => [],
25
25
  prose: (text, line) => this.scan(text, line, uri),
26
26
  snippet: () => [],
27
- bullets: () => []
27
+ bullets: () => [],
28
+ frontmatter: () => []
28
29
  });
29
30
  }
30
31
  scan(text, line, uri) {
@@ -24,7 +24,8 @@ class ShortSections {
24
24
  header: (text, line) => this.named(text, line, uri),
25
25
  prose: () => [],
26
26
  snippet: () => [],
27
- bullets: () => []
27
+ bullets: () => [],
28
+ frontmatter: () => []
28
29
  });
29
30
  }
30
31
  named(text, line, uri) {
package/src/sources.js ADDED
@@ -0,0 +1,42 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ /**
12
+ * Sources.
13
+ *
14
+ * The paths passed on the command line. Each path names either one
15
+ * manifesto file or one directory. A directory expands into the default
16
+ * manifesto files it actually contains, so `dogent .` lints every known
17
+ * manifesto in the current folder.
18
+ */
19
+ class Sources {
20
+ constructor(paths, defaults = ['AGENTS.md', 'CLAUDE.md', 'SKILL.md', 'SKILLS.md']) {
21
+ this.paths = paths;
22
+ this.defaults = defaults;
23
+ }
24
+ files() {
25
+ const found = [];
26
+ this.paths.forEach((entry) => {
27
+ if (fs.statSync(entry).isDirectory()) {
28
+ this.defaults.forEach((name) => {
29
+ const file = path.join(entry, name);
30
+ if (fs.existsSync(file)) {
31
+ found.push(file);
32
+ }
33
+ });
34
+ } else {
35
+ found.push(entry);
36
+ }
37
+ });
38
+ return found;
39
+ }
40
+ }
41
+
42
+ module.exports = Sources;
package/src/yaml.js ADDED
@@ -0,0 +1,37 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * Yaml.
10
+ *
11
+ * A frontmatter block read as a flat YAML mapping. Splits itself line by
12
+ * line and emits one pair per top-level "key: value", carrying the key,
13
+ * the value, and the absolute line the key sits on. Nested mappings,
14
+ * blank lines, and comments hold no keys and yield nothing.
15
+ */
16
+ class Yaml {
17
+ constructor(text, base) {
18
+ this.text = text;
19
+ this.base = base;
20
+ }
21
+ pairs() {
22
+ return this.text
23
+ .split('\n')
24
+ .map((line, index) => ({line, row: this.base + index}))
25
+ .filter((spot) => /^[^\s#][^:]*:/u.test(spot.line))
26
+ .map((spot) => {
27
+ const colon = spot.line.indexOf(':');
28
+ return {
29
+ key: spot.line.slice(0, colon).trim(),
30
+ value: spot.line.slice(colon + 1).trim(),
31
+ row: spot.row
32
+ };
33
+ });
34
+ }
35
+ }
36
+
37
+ module.exports = Yaml;