@yegor256/dogent 0.7.1 → 0.7.3

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
@@ -29,13 +29,7 @@ In short: `agnix` lints the harness, `dogent` lints the prompt.
29
29
  Run it on any manifesto file, no installation required:
30
30
 
31
31
  ```bash
32
- npx @yegor256/dogent@0.6.0 CLAUDE.md
33
- ```
34
-
35
- Lint several files at once:
36
-
37
- ```bash
38
- npx @yegor256/dogent SKILL.md CLAUDE.md AGENTS.md
32
+ npx @yegor256/dogent@0.7.2 SKILL.md
39
33
  ```
40
34
 
41
35
  Point it at a directory to lint the default manifestos it holds
@@ -135,7 +129,7 @@ jobs:
135
129
  - uses: actions/setup-node@v4
136
130
  with:
137
131
  node-version: 20
138
- - run: npx @yegor256/dogent CLAUDE.md SKILL.md
132
+ - run: npx @yegor256/dogent .
139
133
  ```
140
134
 
141
135
  The job fails when `dogent` finds problems,
@@ -164,7 +158,7 @@ Reference `dogent` as a remote hook in `.pre-commit-config.yaml`:
164
158
  ```yaml
165
159
  repos:
166
160
  - repo: https://github.com/yegor256/dogent
167
- rev: 0.6.0
161
+ rev: 0.7.2
168
162
  hooks:
169
163
  - id: dogent
170
164
  ```
package/package.json CHANGED
@@ -40,7 +40,7 @@
40
40
  "lint": "eslint .",
41
41
  "test": "mocha 'test/**/*.js' --timeout 60000"
42
42
  },
43
- "version": "0.7.1",
43
+ "version": "0.7.3",
44
44
  "dependencies": {
45
45
  "minimist": "^1.2.8"
46
46
  }
package/src/markdown.js CHANGED
@@ -20,9 +20,9 @@ const Yaml = require('./yaml');
20
20
  * the context of the moment (inside a fence, inside a list), and emits
21
21
  * a Document of fragments. This is a line scanner, not a grammar.
22
22
  *
23
- * @todo #1:45min Attach wrapped continuation lines to the bullet they
24
- * belong to, instead of silently dropping their nested indentation
25
- * context on the floor.
23
+ * A wrapped continuation line, indented under an open bullet and
24
+ * carrying no marker of its own, folds into the bullet it belongs to
25
+ * rather than breaking the list and floating off as a stray paragraph.
26
26
  */
27
27
  class Markdown {
28
28
  constructor(uri, content) {
@@ -92,6 +92,11 @@ class Markdown {
92
92
  flush();
93
93
  return;
94
94
  }
95
+ if (items.length > 0 && /^\s+\S/u.test(line)) {
96
+ const last = items[items.length - 1];
97
+ items[items.length - 1] = new Prose(`${last.content} ${line.trim()}`, last.row);
98
+ return;
99
+ }
95
100
  flush();
96
101
  pieces.push(new Prose(line, row));
97
102
  });
@@ -15,19 +15,15 @@ const Region = require('../region');
15
15
  * checker only spots the loud signs: a sentence terminator sitting
16
16
  * mid-line with more text after it, or two verb phrases welded together
17
17
  * with a semicolon, an " and ", or a " then ". The prompt hands the
18
- * subtler clause-counting to the AI oracle.
19
- *
20
- * @todo #21:45min Upgrade to a real clause-count check through an AI
21
- * oracle so that subtle multi-instruction lines, which the conservative
22
- * heuristic cannot see today, are reliably caught, as requested in
23
- * issue #21.
18
+ * subtler clause-counting to the AI oracle, which catches the
19
+ * multi-instruction lines that carry no such welding token.
24
20
  */
25
21
  class Atomic {
26
22
  constructor() {
27
23
  this.id = 'atomic';
28
24
  }
29
25
  prompt() {
30
- return `${this.id}: flag any line that carries more than one instruction`;
26
+ return `${this.id}: flag any line that carries more than one instruction, counting distinct clauses even when no semicolon, "and", or "then" welds them together`;
31
27
  }
32
28
  violations(document) {
33
29
  const uri = document.uri();
@@ -10,6 +10,7 @@ const path = require('path');
10
10
 
11
11
  const Violation = require('../violation');
12
12
  const Region = require('../region');
13
+ const Markdown = require('../markdown');
13
14
 
14
15
  const imports = (line) => {
15
16
  const found = [];
@@ -25,42 +26,97 @@ const imports = (line) => {
25
26
  return found;
26
27
  };
27
28
 
29
+ const targets = (file) => {
30
+ const base = path.dirname(file);
31
+ try {
32
+ return new Markdown(file, fs.readFileSync(file, 'utf8'))
33
+ .document()
34
+ .walk({
35
+ header: () => [],
36
+ snippet: () => [],
37
+ bullets: () => [],
38
+ frontmatter: () => [],
39
+ prose: (line) => imports(line).map((item) => path.resolve(base, item.file))
40
+ })
41
+ .filter((target) => fs.existsSync(target));
42
+ } catch {
43
+ return [];
44
+ }
45
+ };
46
+
28
47
  /**
29
48
  * DeadImport.
30
49
  *
31
- * Flags `@path/to/file` imports that point to no file on disk.
32
- *
33
- * @todo #18:45min Detect circular import chains and depth above five
34
- * levels so deeply nested manifesto imports fail with a clear violation,
35
- * as requested in issue #18.
50
+ * Flags `@path/to/file` imports that point to no file on disk, and
51
+ * walks the chain of imports that do resolve: a chain that loops back
52
+ * on itself, or that nests deeper than five levels, fails with a clear
53
+ * violation rather than looping or loading forever in the host tool.
36
54
  */
37
55
  class DeadImport {
38
56
  constructor() {
39
57
  this.id = 'dead-import';
58
+ this.depth = 5;
40
59
  }
41
60
  prompt() {
42
61
  return `${this.id}: flag any @path/to/file import that points to no file on disk`;
43
62
  }
44
63
  violations(document) {
45
- return document.walk({
64
+ const uri = document.uri();
65
+ const base = path.dirname(uri);
66
+ const links = document.walk({
46
67
  header: () => [],
47
68
  snippet: () => [],
48
69
  bullets: () => [],
49
70
  frontmatter: () => [],
50
- prose: (line, row) => this.missing(document.uri(), line, row)
71
+ prose: (line, row) => imports(line).map(
72
+ (item) => ({...item, target: path.resolve(base, item.file), row})
73
+ )
51
74
  });
75
+ return this.missing(uri, links).concat(this.chains(uri, links));
52
76
  }
53
- missing(uri, line, row) {
54
- const base = path.dirname(uri);
55
- return imports(line)
56
- .filter((item) => !fs.existsSync(path.resolve(base, item.file)))
57
- .map((item) => new Violation(
77
+ missing(uri, links) {
78
+ return links
79
+ .filter((link) => !fs.existsSync(link.target))
80
+ .map((link) => new Violation(
58
81
  this.id,
59
82
  'error',
60
- `@-import target not found: ${item.file}`,
61
- new Region(uri, row, item.column)
83
+ `@-import target not found: ${link.file}`,
84
+ new Region(uri, link.row, link.column)
62
85
  ));
63
86
  }
87
+ chains(uri, links) {
88
+ const root = path.resolve(uri);
89
+ const found = [];
90
+ links
91
+ .filter((link) => fs.existsSync(link.target))
92
+ .forEach((link) => {
93
+ const flags = {cycle: false, deep: false};
94
+ this.explore([root, link.target], 1, flags);
95
+ if (flags.cycle) {
96
+ found.push(this.flag(uri, link, `@-import chain is circular via ${link.file}`));
97
+ }
98
+ if (flags.deep) {
99
+ found.push(this.flag(uri, link, `@-import chain nests deeper than ${this.depth} levels via ${link.file}`));
100
+ }
101
+ });
102
+ return found;
103
+ }
104
+ explore(stack, depth, flags) {
105
+ if (depth > this.depth) {
106
+ flags.deep = true;
107
+ return;
108
+ }
109
+ targets(stack[stack.length - 1]).forEach((target) => {
110
+ if (stack.includes(target)) {
111
+ flags.cycle = true;
112
+ return;
113
+ }
114
+ this.explore(stack.concat(target), depth + 1, flags);
115
+ });
116
+ }
117
+ flag(uri, link, message) {
118
+ return new Violation(this.id, 'error', message, new Region(uri, link.row, link.column));
119
+ }
64
120
  }
65
121
 
66
122
  module.exports = DeadImport;
@@ -14,11 +14,8 @@ const Region = require('../region');
14
14
  * Demands that a SKILL.md description say when to use the skill. A
15
15
  * standalone checker can only approximate: it flags a value that is too
16
16
  * short or that never names a trigger with the word "when". Its prompt
17
- * hands the deeper judgement to the AI oracle.
18
- *
19
- * @todo #19:30min Upgrade the trigger check to an AI oracle that judges
20
- * whether the description truly names the situations and user phrases
21
- * that should activate the skill, as requested in issue #19.
17
+ * hands the deeper judgement to the AI oracle, which weighs whether the
18
+ * description truly names the situations and phrases that activate it.
22
19
  */
23
20
  class DescriptionTriggers {
24
21
  constructor() {
@@ -26,7 +23,7 @@ class DescriptionTriggers {
26
23
  this.minimum = 20;
27
24
  }
28
25
  prompt() {
29
- return `${this.id}: in a SKILL.md, flag a description that is too short or never says when to use the skill`;
26
+ return `${this.id}: in a SKILL.md, flag a description that is too short or fails to name the concrete situations and user phrases that should activate the skill, even when it contains the word "when"`;
30
27
  }
31
28
  violations(document) {
32
29
  const uri = document.uri();
@@ -13,19 +13,16 @@ const Region = require('../region');
13
13
  *
14
14
  * Flags soft, non-committal, or hedging wording that weakens an order.
15
15
  * Catches words like "should", "just", "usually", and phrases like
16
- * "try to" or "if possible", each a sign of timid instruction.
17
- *
18
- * @todo #22:30min Upgrade to an AI oracle that catches subtler hedging,
19
- * such as conditional escape hatches and vague scope, which the fixed
20
- * blacklist of hedge words cannot detect today, as requested in
21
- * issue #22.
16
+ * "try to" or "if possible", each a sign of timid instruction. Its
17
+ * prompt hands subtler hedging to the AI oracle, which catches the
18
+ * conditional escape hatches and vague scope no fixed list can.
22
19
  */
23
20
  class Hedging {
24
21
  constructor() {
25
22
  this.id = 'hedging';
26
23
  }
27
24
  prompt() {
28
- return `${this.id}: flag soft, non-committal, or hedging wording`;
25
+ return `${this.id}: flag soft, non-committal, or hedging wording, including conditional escape hatches and vague scope that carry no fixed hedge word`;
29
26
  }
30
27
  violations(document) {
31
28
  const uri = document.uri();
@@ -13,19 +13,16 @@ const Region = require('../region');
13
13
  *
14
14
  * Demands active imperative voice. A standalone checker can only guess:
15
15
  * it flags a "be" verb followed, perhaps through an adverb, by a past
16
- * participle, the surest mark of passive voice.
17
- *
18
- * @todo #24:45min Upgrade to an AI oracle for accurate passive-voice
19
- * detection, since the regular-expression heuristic both misses many
20
- * irregular participles and cannot judge true grammatical voice, as
21
- * requested in issue #24.
16
+ * participle, the surest mark of passive voice. Its prompt hands true
17
+ * grammatical-voice judgement to the AI oracle, which catches the
18
+ * irregular participles the regular expression misses.
22
19
  */
23
20
  class Passive {
24
21
  constructor() {
25
22
  this.id = 'passive';
26
23
  }
27
24
  prompt() {
28
- return `${this.id}: flag any instruction written in passive voice`;
25
+ return `${this.id}: flag any instruction written in passive voice, judging true grammatical voice including irregular past participles a fixed pattern misses`;
29
26
  }
30
27
  violations(document) {
31
28
  const uri = document.uri();
@@ -44,14 +44,10 @@ const PHRASES = [
44
44
  * Flags a line that restates default model behavior, like
45
45
  * "Be helpful and accurate" or "Write clean code". Such filler
46
46
  * burns the context budget and drowns the project-specific
47
- * guidance the manifesto exists to carry.
48
- *
49
- * @todo #15:60min Promote the standalone heuristic into a
50
- * proper AI-oracle check when `OPENAI_API_KEY` is present, so
51
- * redundancy detection covers paraphrases beyond the curated
52
- * blacklist below. The hybrid pattern from `src/rules/command.js`
53
- * is the model: keep the deterministic check as the default,
54
- * let the oracle catch the rest.
47
+ * guidance the manifesto exists to carry. Following the hybrid
48
+ * pattern of `command.js`, the curated blacklist below stays the
49
+ * deterministic default and the prompt hands paraphrases beyond
50
+ * that list to the AI oracle.
55
51
  */
56
52
  class Redundant {
57
53
  constructor(phrases = PHRASES) {
@@ -59,7 +55,7 @@ class Redundant {
59
55
  this.phrases = phrases;
60
56
  }
61
57
  prompt() {
62
- return `${this.id}: flag any line that restates default agent behavior already known to the model, not a project-specific instruction`;
58
+ return `${this.id}: flag any line that restates default agent behavior already known to the model, not a project-specific instruction, including reworded paraphrases that match no fixed phrase list`;
63
59
  }
64
60
  violations(document) {
65
61
  const uri = document.uri();
@@ -13,19 +13,15 @@ const Region = require('../region');
13
13
  *
14
14
  * Demands simple grammar over ambiguity. A standalone checker can only
15
15
  * guess: it counts commas and conjunctions to flag lines that pile up
16
- * clauses. Its prompt hands the subtler tangle judgement to the oracle.
17
- *
18
- * @todo #29:45min Upgrade to true clause-depth analysis through an AI
19
- * oracle so that subtle tangled instructions, which the comma and
20
- * conjunction heuristic cannot measure today, are reliably caught, as
21
- * requested in issue #29.
16
+ * clauses. Its prompt hands the subtler tangle judgement to the oracle,
17
+ * which weighs true clause depth rather than counting punctuation.
22
18
  */
23
19
  class Simple {
24
20
  constructor() {
25
21
  this.id = 'simple';
26
22
  }
27
23
  prompt() {
28
- return `${this.id}: flag any grammatically tangled, multi-clause instruction`;
24
+ return `${this.id}: flag any grammatically tangled, multi-clause instruction, judging true clause depth even when the line carries few commas or conjunctions`;
29
25
  }
30
26
  violations(document) {
31
27
  const uri = document.uri();
@@ -23,18 +23,16 @@ const normalize = (text) => {
23
23
  *
24
24
  * Flags any instruction that repeats another instruction in the file.
25
25
  * It normalizes each prose line, then remembers the first line where
26
- * each normal form appeared, so a later twin earns one violation.
27
- *
28
- * @todo #25:45min Upgrade to semantic near-duplicate detection through
29
- * embeddings or an AI oracle, to catch same-meaning different-words
30
- * pairs the normalizer misses, as requested in issue #25.
26
+ * each normal form appeared, so a later twin earns one violation. Its
27
+ * prompt hands semantic near-duplicates to the AI oracle, catching
28
+ * same-meaning different-words pairs the normalizer misses.
31
29
  */
32
30
  class Unique {
33
31
  constructor() {
34
32
  this.id = 'unique';
35
33
  }
36
34
  prompt() {
37
- return `${this.id}: flag any instruction that repeats another instruction in the file`;
35
+ return `${this.id}: flag any instruction that repeats another instruction in the file, including two lines that carry the same meaning in different words, not only lines matching after normalized case, punctuation, and word order`;
38
36
  }
39
37
  violations(document) {
40
38
  const uri = document.uri();
package/src/yaml.js CHANGED
@@ -10,8 +10,9 @@
10
10
  *
11
11
  * A frontmatter block read as a flat YAML mapping. Splits itself line by
12
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.
13
+ * the value, and the absolute line the key sits on. Folds a block scalar
14
+ * value ("|" or ">") from its indented continuation lines into one value.
15
+ * Nested mappings, blank lines, and comments hold no keys and yield nothing.
15
16
  */
16
17
  class Yaml {
17
18
  constructor(text, base) {
@@ -19,18 +20,30 @@ class Yaml {
19
20
  this.base = base;
20
21
  }
21
22
  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
- });
23
+ const lines = this.text.split('\n');
24
+ return lines
25
+ .map((line, index) => index)
26
+ .filter((index) => /^[^\s#][^:]*:/u.test(lines[index]))
27
+ .map((index) => this.pair(lines, index));
28
+ }
29
+ pair(lines, index) {
30
+ const colon = lines[index].indexOf(':');
31
+ const value = lines[index].slice(colon + 1).trim();
32
+ return {
33
+ key: lines[index].slice(0, colon).trim(),
34
+ value: /^[|>][+-]?\d*$/u.test(value) ? Yaml.fold(lines, index) : value,
35
+ row: this.base + index
36
+ };
37
+ }
38
+ static fold(lines, index) {
39
+ const rest = lines.slice(index + 1);
40
+ const stop = rest.findIndex((line) => line.trim() !== '' && !/^\s/u.test(line));
41
+ const end = stop === -1 ? rest.length : stop;
42
+ return rest
43
+ .slice(0, end)
44
+ .map((line) => line.trim())
45
+ .join(' ')
46
+ .trim();
34
47
  }
35
48
  }
36
49