@yegor256/dogent 0.9.1 → 0.11.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.
Files changed (51) hide show
  1. package/README.md +104 -12
  2. package/package.json +3 -2
  3. package/src/args.js +35 -4
  4. package/src/defaults.js +47 -0
  5. package/src/dogent.js +42 -16
  6. package/src/openai.js +8 -5
  7. package/src/prompt.js +0 -4
  8. package/src/report.js +8 -2
  9. package/src/rules/ambiguous-or.js +58 -0
  10. package/src/rules/budget.js +50 -0
  11. package/src/rules/concise.js +48 -0
  12. package/src/rules/conditional.js +55 -0
  13. package/src/rules/consistent.js +1 -1
  14. package/src/rules/counter-example.js +60 -0
  15. package/src/rules/default.js +60 -0
  16. package/src/rules/description-length.js +64 -0
  17. package/src/rules/description-voice.js +67 -0
  18. package/src/rules/done.js +53 -0
  19. package/src/rules/duplicate-section.js +65 -0
  20. package/src/rules/emoji.js +60 -0
  21. package/src/rules/emphasis.js +81 -0
  22. package/src/rules/example-format.js +32 -0
  23. package/src/rules/example.js +60 -0
  24. package/src/rules/external-link.js +57 -0
  25. package/src/rules/fence-language.js +55 -0
  26. package/src/rules/format.js +68 -0
  27. package/src/rules/hidden-char.js +61 -0
  28. package/src/rules/homoglyph.js +82 -0
  29. package/src/rules/index.js +80 -0
  30. package/src/rules/inline-code.js +79 -0
  31. package/src/rules/jargon.js +115 -0
  32. package/src/rules/meta-reference.js +57 -0
  33. package/src/rules/ordered.js +57 -0
  34. package/src/rules/persona.js +55 -0
  35. package/src/rules/placement.js +62 -0
  36. package/src/rules/positive.js +57 -0
  37. package/src/rules/pseudo-heading.js +55 -0
  38. package/src/rules/quantifier.js +63 -0
  39. package/src/rules/rationale.js +54 -0
  40. package/src/rules/referential.js +67 -0
  41. package/src/rules/scope.js +31 -0
  42. package/src/rules/self-contained.js +66 -0
  43. package/src/rules/stale.js +62 -0
  44. package/src/rules/terms.js +77 -0
  45. package/src/rules/tool-clarity.js +61 -0
  46. package/src/rules/transition.js +59 -0
  47. package/src/rules/units.js +81 -0
  48. package/src/rules/untrusted.js +59 -0
  49. package/src/rules/vague.js +63 -0
  50. package/src/rules/weak-verb.js +62 -0
  51. package/src/version.js +2 -2
package/README.md CHANGED
@@ -13,23 +13,47 @@ Vague, bloated, or ambiguous instructions make agents behave unpredictably.
13
13
  `dogent` enforces a clear, command-style discipline
14
14
  so every line earns its place.
15
15
 
16
- We respect [agent-sh/agnix](https://github.com/agent-sh/agnix)
17
- as a prototype of this idea, but the two lint different layers.
18
- `agnix` validates the harness around a prompt frontmatter schema,
19
- hook JSON, MCP config, tool wiring — asking whether the configuration
20
- is well-formed and correctly wired.
21
- `dogent` lints the prose of the instructions themselves,
22
- asking whether every line is a tight, unambiguous command.
23
- In short: `agnix` lints the harness, `dogent` lints the prompt.
24
- `dogent` is the stricter, more opinionated of the two,
25
- aiming for extreme quality with no compromise.
16
+ ## Alternatives
17
+
18
+ Several tools sit near this problem, yet none lint manifesto prose
19
+ the way we do.
20
+ Most rewrite prompts for you or score a file, while we enforce
21
+ line-level discipline and fail the build when one line breaks it.
22
+
23
+ - [agent-sh/agnix](https://github.com/agent-sh/agnix)
24
+ validates the harness — frontmatter schema, hook JSON, MCP config, tools —
25
+ while we lint the prose, asking if each line is a tight command.
26
+
27
+ - [AgentLinter](https://agentlinter.com)
28
+ lints the same files but scores them 0–100 across eight dimensions,
29
+ while we run a strict pass/fail gate, not a scorer.
30
+
31
+ - [microsoft/SkillOpt](https://github.com/microsoft/SkillOpt)
32
+ rewrites `skill.md` against benchmarks to raise an agent's task score,
33
+ while we never rewrite, need no benchmarks, and judge discipline.
34
+
35
+ - [linshenkx/prompt-optimizer](https://github.com/linshenkx/prompt-optimizer)
36
+ rewrites a prompt through an LLM in the browser, round by round,
37
+ while we lint manifesto files offline in CI.
38
+
39
+ - [DSPy](https://github.com/stanfordnlp/dspy)
40
+ tunes prompts as programs to maximize a metric on a dataset,
41
+ while we lint hand-written manifestos, demanding clarity over a score.
42
+
43
+ - Prompt platforms like [LangSmith](https://www.langchain.com/langsmith),
44
+ [PromptLayer](https://www.promptlayer.com), and
45
+ [Braintrust](https://www.braintrust.dev)
46
+ version, trace, and evaluate prompts from runtime data,
47
+ while we lint statically and offline, needing no traces or account.
26
48
 
27
49
  ## Usage
28
50
 
51
+ [Watch the demo](https://github.com/user-attachments/assets/0f05c975-2bc8-4a95-9c3a-76a78fe5d655)
52
+
29
53
  Run it on any manifesto file, no installation required:
30
54
 
31
55
  ```bash
32
- npx @yegor256/dogent@0.7.2 SKILL.md
56
+ npx @yegor256/dogent@0.10.0 SKILL.md
33
57
  ```
34
58
 
35
59
  Point it at a directory to lint the default manifestos it holds
@@ -67,20 +91,30 @@ The command exits with a non-zero status when problems are found,
67
91
  - Every section must be a level-2 (`##`) heading, below the lone `#` title.
68
92
  - Every line must be no longer than 80 symbols.
69
93
  - The whole file must stay under 4000 tokens.
94
+ - The whole file must stay short; split detail into referenced files.
70
95
  - Every line must sound like a command.
71
96
  - Every sentence must start with a capital and end with a period.
72
97
  - No articles, no noise, no bloated text.
73
98
  - Simple grammar, no ambiguity.
99
+ - No bare pronoun subjects; name the subject on the line.
74
100
  - No tangled, multi-clause instructions.
101
+ - Sequential steps must be a numbered list, not bullets.
75
102
  - A `SKILL.md` `name` must equal its parent directory.
76
103
  - No courtesy or scaffolding words.
77
104
  - No leftover markers or unfilled placeholders.
78
105
  - A section must hold at most ten instructions.
79
106
  - A `SKILL.md` `description` must say when to use the skill.
107
+ - A `SKILL.md` must carry at least one worked example.
108
+ - A `SKILL.md` that produces output must declare its format.
80
109
  - Every line must carry exactly one instruction.
81
110
  - No hedging or soft wording.
111
+ - No vague qualifiers; demand a measurable criterion.
82
112
  - No passive voice; use the active imperative.
113
+ - No ALL-CAPS shouting or "!!" markers; state it plainly.
114
+ - No persona or role-play; it adds no instruction.
115
+ - No negative phrasing; state the positive command instead.
83
116
  - No instruction may repeat another.
117
+ - No unguarded consumption of untrusted external input.
84
118
  - `SKILL.md` must open with valid frontmatter.
85
119
  - Frontmatter must declare only allowed keys.
86
120
  - A `SKILL.md` `name` must be kebab-case.
@@ -96,6 +130,11 @@ It sends the manifesto together with one instruction per rule,
96
130
  then prints any violation the model reports for ambiguity,
97
131
  weak phrasing, and instructions that only pretend to be commands.
98
132
  The model defaults to `gpt-4o-mini`; override it with `OPENAI_MODEL`.
133
+ Requests go to `https://api.openai.com/v1` by default;
134
+ set `OPENAI_BASE_URL` to any OpenAI-compatible endpoint instead,
135
+ such as a local vLLM, Ollama, or LocalAI server, or a private gateway.
136
+ For a keyless local server, set `OPENAI_API_KEY` to any placeholder,
137
+ since the server ignores it yet `dogent` calls the LLM only when it is set.
99
138
  After the report, `dogent` prints a one-line usage summary to standard error,
100
139
  naming the model, the tokens sent and received, and an estimated cost,
101
140
  for example `OpenAI: gpt-4o-mini, 1234 sent, 567 received, ~$0.0005`.
@@ -105,6 +144,15 @@ export OPENAI_API_KEY=...
105
144
  npx @yegor256/dogent CLAUDE.md
106
145
  ```
107
146
 
147
+ Point it at a local model the same way:
148
+
149
+ ```bash
150
+ export OPENAI_BASE_URL=http://localhost:11434/v1
151
+ export OPENAI_MODEL=llama3
152
+ export OPENAI_API_KEY=dummy
153
+ npx @yegor256/dogent CLAUDE.md
154
+ ```
155
+
108
156
  Pass `--offline` to keep `dogent` away from the LLM,
109
157
  even when `OPENAI_API_KEY` is present in the environment:
110
158
 
@@ -114,6 +162,50 @@ npx @yegor256/dogent --offline CLAUDE.md
114
162
 
115
163
  Pass `--sarif` to print the report as SARIF instead of plain text.
116
164
 
165
+ Pass `--suppress` to silence a rule by its id. Repeat the option or
166
+ join several ids with commas to silence many at once:
167
+
168
+ ```bash
169
+ npx @yegor256/dogent --suppress=name-matches-dir,line-length CLAUDE.md
170
+ ```
171
+
172
+ Pass `--openai-http-header` to add one `Name: Value` header to every OpenAI
173
+ call, handy for a gateway that wants its own token beside the API key.
174
+ Repeat the option to add several headers:
175
+
176
+ ```bash
177
+ npx @yegor256/dogent \
178
+ --openai-http-header='X-Api-Key: secret' \
179
+ --openai-http-header='X-Tenant: acme' \
180
+ CLAUDE.md
181
+ ```
182
+
183
+ A header value often holds a secret, so keep it out of your shell history
184
+ by listing the option in a `.dogent` file (see below) instead.
185
+
186
+ ## Defaults file
187
+
188
+ Tired of repeating the same flags on every run?
189
+ Drop a `.dogent` file beside your project, and `dogent` reads its options
190
+ as defaults before it touches the command line.
191
+ The file holds one option per line, written exactly as you would type it,
192
+ with the value after a space (for example `--suppress name-matches-dir`).
193
+ A blank line is ignored, and a line that starts with `#` is a comment:
194
+
195
+ ```text
196
+ # project defaults for dogent
197
+ --offline
198
+ --suppress name-matches-dir
199
+ --suppress line-length
200
+ ```
201
+
202
+ `dogent` looks first in the current directory, then in your home directory,
203
+ and reads the first `.dogent` it finds.
204
+ The file only supplies defaults, so any option you type on the command line
205
+ overrides the same option in the file.
206
+ Thus `npx @yegor256/dogent --sarif .` still prints SARIF even when the file
207
+ says nothing about it, and a hand-typed flag always wins.
208
+
117
209
  ## GitHub Actions
118
210
 
119
211
  Because `dogent` runs through `npx`, no extra action is needed.
@@ -159,7 +251,7 @@ Reference `dogent` as a remote hook in `.pre-commit-config.yaml`:
159
251
  ```yaml
160
252
  repos:
161
253
  - repo: https://github.com/yegor256/dogent
162
- rev: 0.7.2
254
+ rev: 0.10.0
163
255
  hooks:
164
256
  - id: dogent
165
257
  ```
package/package.json CHANGED
@@ -40,8 +40,9 @@
40
40
  "lint": "eslint .",
41
41
  "test": "mocha 'test/**/*.js' --timeout 60000"
42
42
  },
43
- "version": "0.9.1",
43
+ "version": "0.11.0",
44
44
  "dependencies": {
45
- "minimist": "^1.2.8"
45
+ "minimist": "^1.2.8",
46
+ "pretty-ms": "^7.0.1"
46
47
  }
47
48
  }
package/src/args.js CHANGED
@@ -15,13 +15,24 @@ const minimist = require('minimist');
15
15
  * The `--sarif` flag switches the report to SARIF, while `--offline` forbids
16
16
  * any talk to the LLM even when a token sits in the environment. The `--help`
17
17
  * flag, also spelled `-h`, asks for the usage banner. The `--version` flag
18
- * asks for the release number. Everything after a `--` separator counts as a
18
+ * asks for the release number. The `--suppress` option names a rule to
19
+ * silence; repeat it or join names with commas to silence many at once. The
20
+ * `--openai-http-header` option adds one `Name: Value` header to every OpenAI
21
+ * call; repeat it to add many. Everything after a `--` separator counts as a
19
22
  * path, never as an option.
20
23
  */
21
24
  class Args {
22
- constructor(argv, flags = ['sarif', 'offline', 'help', 'version']) {
25
+ constructor(
26
+ argv,
27
+ flags = ['sarif', 'offline', 'help', 'version'],
28
+ options = ['suppress', 'openai-http-header']
29
+ ) {
23
30
  this.flags = flags;
24
- this.parsed = minimist(argv, {boolean: flags, alias: {help: 'h'}, '--': true});
31
+ this.options = options;
32
+ this.parsed = minimist(
33
+ argv,
34
+ {boolean: flags, string: options, alias: {help: 'h'}, '--': true}
35
+ );
25
36
  }
26
37
  sarif() {
27
38
  return this.parsed.sarif === true;
@@ -35,12 +46,32 @@ class Args {
35
46
  version() {
36
47
  return this.parsed.version === true;
37
48
  }
49
+ suppress() {
50
+ return [].concat(this.parsed.suppress || [])
51
+ .flatMap((item) => String(item).split(','))
52
+ .map((name) => name.trim())
53
+ .filter((name) => name !== '');
54
+ }
55
+ headers() {
56
+ return [].concat(this.parsed['openai-http-header'] || [])
57
+ .map(String)
58
+ .map((line) => line.trim())
59
+ .filter((line) => line !== '')
60
+ .reduce((acc, line) => {
61
+ const at = line.indexOf(':');
62
+ if (at < 0) {
63
+ throw new Error(`Malformed header "${line}", expected "Name: Value"`);
64
+ }
65
+ return {...acc, [line.slice(0, at).trim()]: line.slice(at + 1).trim()};
66
+ }, {});
67
+ }
38
68
  paths() {
39
69
  return this.parsed._.concat(this.parsed['--']).map(String);
40
70
  }
41
71
  unknown() {
42
72
  return Object.keys(this.parsed)
43
- .filter((key) => key !== '_' && key !== '--' && key !== 'h' && !this.flags.includes(key))
73
+ .filter((key) => key !== '_' && key !== '--' && key !== 'h' &&
74
+ !this.flags.includes(key) && !this.options.includes(key))
44
75
  .map((key) => `${key.length === 1 ? '-' : '--'}${key}`);
45
76
  }
46
77
  }
@@ -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 fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ /**
13
+ * Defaults.
14
+ *
15
+ * The default command-line options pulled from a `.dogent` file. Dogent looks
16
+ * first in the current directory, then in the user home, and reads the first
17
+ * file it finds. Each line names one option, written exactly as typed on the
18
+ * command line, with its value after a space. A blank line vanishes, and a
19
+ * line that opens with `#` reads as a comment. These options sit ahead of the
20
+ * real argv, so any flag typed by hand overrides the file.
21
+ */
22
+ class Defaults {
23
+ constructor(
24
+ dirs = [process.cwd(), os.homedir()],
25
+ exists = (file) => fs.existsSync(file),
26
+ reader = (file) => fs.readFileSync(file, 'utf8')
27
+ ) {
28
+ this.dirs = dirs;
29
+ this.exists = exists;
30
+ this.reader = reader;
31
+ }
32
+ argv() {
33
+ const file = this.dirs
34
+ .map((dir) => path.join(dir, '.dogent'))
35
+ .find((candidate) => this.exists(candidate));
36
+ if (!file) {
37
+ return [];
38
+ }
39
+ return this.reader(file)
40
+ .split('\n')
41
+ .map((line) => line.trim())
42
+ .filter((line) => line !== '' && !line.startsWith('#'))
43
+ .flatMap((line) => line.split(/\s+/u));
44
+ }
45
+ }
46
+
47
+ module.exports = Defaults;
package/src/dogent.js CHANGED
@@ -8,18 +8,20 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const Args = require('./args');
11
+ const Defaults = require('./defaults');
11
12
  const Markdown = require('./markdown');
12
13
  const Report = require('./report');
13
14
  const Sources = require('./sources');
14
15
  const Openai = require('./openai');
15
16
  const Oracle = require('./oracle');
16
17
  const Usage = require('./usage');
18
+ const prettyMs = require('pretty-ms');
17
19
  const version = require('./version');
18
20
  const rules = require('./rules');
19
21
 
20
- const args = new Args(process.argv.slice(2));
22
+ const args = new Args(new Defaults().argv().concat(process.argv.slice(2)));
21
23
  const sarif = args.sarif();
22
- const banner = 'Usage: dogent [--sarif] [--offline] <file.md|dir>...';
24
+ const banner = 'Usage: dogent [--sarif] [--offline] [--suppress=RULE,...] <file.md|dir>...';
23
25
  if (args.version()) {
24
26
  process.stdout.write(`${version}\n`);
25
27
  process.exit(0);
@@ -31,8 +33,13 @@ if (args.help()) {
31
33
  'Options:\n' +
32
34
  ' --sarif render the report as SARIF JSON\n' +
33
35
  ' --offline never call the LLM, even when a token exists\n' +
36
+ ' --suppress silence a rule by id; repeat or comma-join to silence many\n' +
37
+ ' --openai-http-header add a "Name: Value" header to OpenAI calls\n' +
34
38
  ' --version show the version and exit\n' +
35
- ' --help show this help and exit\n'
39
+ ' --help show this help and exit\n\n' +
40
+ 'Defaults:\n' +
41
+ ' A .dogent file in the current directory or home holds default\n' +
42
+ ' options, one per line; options typed by hand override it.\n'
36
43
  );
37
44
  process.exit(0);
38
45
  }
@@ -42,6 +49,13 @@ if (unknown.length > 0) {
42
49
  process.stderr.write(`${banner}\n`);
43
50
  process.exit(2);
44
51
  }
52
+ let headers = {};
53
+ try {
54
+ headers = args.headers();
55
+ } catch (error) {
56
+ process.stderr.write(`${error.message}\n`);
57
+ process.exit(2);
58
+ }
45
59
  const paths = args.paths();
46
60
  if (paths.length === 0) {
47
61
  process.stderr.write(`${banner}\n`);
@@ -49,24 +63,32 @@ if (paths.length === 0) {
49
63
  }
50
64
  const scanned = new Sources(paths).files();
51
65
  scanned.forEach((file) => process.stderr.write(`Scanning ${file}\n`));
52
- process.stderr.write(`${scanned.length} files scanned\n`);
66
+ const checks = rules();
67
+ process.stderr.write(`${scanned.length} files scanned, ${checks.length} rules applied\n`);
53
68
  const documents = scanned.map(
54
69
  (file) => new Markdown(file, fs.readFileSync(file, 'utf8')).document()
55
70
  );
71
+ const started = Date.now();
72
+ const suppressed = args.suppress();
73
+ const allowed = (violation) => !suppressed.includes(violation.rule);
56
74
  const found = [];
57
75
  documents.forEach((document) => {
58
- rules().forEach((rule) => {
59
- rule.violations(document).forEach((violation) => found.push(violation));
76
+ checks.forEach((rule) => {
77
+ rule.violations(document).filter(allowed).forEach((violation) => found.push(violation));
60
78
  });
61
79
  });
62
80
  const key = process.env.OPENAI_API_KEY;
63
81
  const audit = async (docs) => {
64
82
  const oracle = new Oracle(
65
- rules(),
83
+ checks,
66
84
  new Openai(
67
85
  key,
68
86
  process.env.OPENAI_MODEL || 'gpt-4o-mini',
69
- (url, options) => globalThis.fetch(url, options)
87
+ process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
88
+ (url, options) => globalThis.fetch(
89
+ url,
90
+ {...options, headers: {...options.headers, ...headers}}
91
+ )
70
92
  )
71
93
  );
72
94
  const replies = await Promise.all(docs.map((doc) => oracle.violations(doc)));
@@ -78,25 +100,29 @@ const audit = async (docs) => {
78
100
  {extra: [], usage: new Usage('', 0, 0)}
79
101
  );
80
102
  };
81
- const finish = (usage) => {
82
- const report = new Report('dogent', found);
103
+ const finish = (usage, aiMillis) => {
104
+ const report = new Report('dogent', found, Date.now() - started);
83
105
  process.stdout.write(`${sarif ? JSON.stringify(report.sarif(), null, 2) : report.text()}\n`);
84
106
  if (usage !== null) {
85
- process.stderr.write(`${usage.text()}\n`);
107
+ process.stderr.write(`${usage.text()}, analysed in ${prettyMs(aiMillis)}\n`);
86
108
  }
87
109
  process.exit(report.count() > 0 ? 1 : 0);
88
110
  };
111
+ const verify = async () => {
112
+ const clock = Date.now();
113
+ const result = await audit(documents);
114
+ result.extra.filter(allowed).forEach((violation) => found.push(violation));
115
+ return {usage: result.usage, aiMillis: Date.now() - clock};
116
+ };
89
117
  (async () => {
90
- let usage = null;
118
+ let outcome = {aiMillis: 0, usage: null};
91
119
  if (found.length === 0 && key && !args.offline()) {
92
120
  try {
93
- const result = await audit(documents);
94
- result.extra.forEach((violation) => found.push(violation));
95
- ({usage} = result);
121
+ outcome = await verify();
96
122
  } catch (error) {
97
123
  process.stderr.write(`AI verification failed: ${error.message}\n`);
98
124
  process.exit(2);
99
125
  }
100
126
  }
101
- finish(usage);
127
+ finish(outcome.usage, outcome.aiMillis);
102
128
  })();
package/src/openai.js CHANGED
@@ -10,20 +10,23 @@ const Usage = require('./usage');
10
10
  /**
11
11
  * Openai.
12
12
  *
13
- * A thin adapter over the OpenAI chat-completions endpoint. Sends one
14
- * prompt, demands a JSON object back, and returns the assistant text
15
- * paired with a Usage tally of the model and the tokens it consumed.
13
+ * A thin adapter over an OpenAI-compatible chat-completions endpoint.
14
+ * Sends one prompt, demands a JSON object back, and returns the assistant
15
+ * text paired with a Usage tally of the model and the tokens it consumed.
16
+ * The base URL is configurable, so the same adapter reaches OpenAI itself
17
+ * or any compatible server, such as vLLM, Ollama, or a private gateway.
16
18
  * The transport is injected so the class runs in tests without a socket.
17
19
  */
18
20
  class Openai {
19
- constructor(key, model, transport) {
21
+ constructor(key, model, base, transport) {
20
22
  this.key = key;
21
23
  this.model = model;
24
+ this.base = base;
22
25
  this.transport = transport;
23
26
  }
24
27
  async answer(prompt) {
25
28
  const response = await this.transport(
26
- 'https://api.openai.com/v1/chat/completions',
29
+ `${this.base.replace(/\/+$/u, '')}/chat/completions`,
27
30
  {
28
31
  method: 'POST',
29
32
  headers: {
package/src/prompt.js CHANGED
@@ -41,10 +41,6 @@ class Prompt {
41
41
  'report nothing and return {"results": []}. Never invent a',
42
42
  'violation to seem useful, and never flag a line merely for',
43
43
  'sitting in a plain, on-topic section.',
44
- 'One clash is never a false alarm: when two lines give the same',
45
- 'order twice, or demand opposite things, report each offending',
46
- 'line with high confidence, even while every other check stays',
47
- 'conservative. A clean file has no such clash and stays silent.',
48
44
  'Report a violation only when you are certain it breaks a check.',
49
45
  'Give every result a "confidence" number from 0 to 1, your own',
50
46
  'probability that the violation is real, and omit any result you',
package/src/report.js CHANGED
@@ -5,24 +5,30 @@
5
5
 
6
6
  'use strict';
7
7
 
8
+ const prettyMs = require('pretty-ms');
9
+
8
10
  /**
9
11
  * Report.
10
12
  *
11
13
  * The whole verdict of a run: the tool that produced it and every
12
14
  * violation it gathered. Renders itself for humans or as a SARIF log.
15
+ * When handed the analysis duration in milliseconds, the human text
16
+ * closes with a friendly "in 340ms" rendered through pretty-ms.
13
17
  */
14
18
  class Report {
15
- constructor(tool, violations) {
19
+ constructor(tool, violations, millis = null) {
16
20
  this.tool = tool;
17
21
  this.bag = violations;
22
+ this.millis = millis;
18
23
  }
19
24
  count() {
20
25
  return this.bag.length;
21
26
  }
22
27
  text() {
28
+ const suffix = this.millis === null ? '' : ` in ${prettyMs(this.millis)}`;
23
29
  return this.bag
24
30
  .map((violation) => violation.text())
25
- .concat(`${this.bag.length} problems found`)
31
+ .concat(`${this.bag.length} problems found${suffix}`)
26
32
  .join('\n');
27
33
  }
28
34
  sarif() {
@@ -0,0 +1,58 @@
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
+ const mask = require('../mask');
11
+
12
+ /**
13
+ * AmbiguousOr.
14
+ *
15
+ * An "either-or" choice left to the reader is an instruction the agent
16
+ * cannot follow without guessing. This flags the literal "and/or" and a
17
+ * two-term slashed alternative like "tabs/spaces", each demanding the
18
+ * author pick one branch and state exactly which option applies. Inline
19
+ * code is masked first, so a backticked path such as `src/app` never
20
+ * matches. Subtler ambiguities stay with the oracle; prompt() defers
21
+ * the open-ended either-or judgement to it.
22
+ */
23
+ class AmbiguousOr {
24
+ constructor() {
25
+ this.id = 'ambiguous-or';
26
+ }
27
+ prompt() {
28
+ return `${this.id}: flag either-or ambiguity beyond "and/or" and slashed alternatives, and state exactly which option applies`;
29
+ }
30
+ violations(document) {
31
+ const uri = document.uri();
32
+ return document.walk({
33
+ header: () => [],
34
+ prose: (text, line) => this.scan(text, line, uri),
35
+ snippet: () => [],
36
+ bullets: () => [],
37
+ frontmatter: () => []
38
+ });
39
+ }
40
+ scan(text, line, uri) {
41
+ const masked = mask(text);
42
+ const pattern = /\band\/or\b|(?<![\w./])[A-Za-z]{2,}\/[A-Za-z]{2,}(?![\w/]|\.\w)/giu;
43
+ const found = [];
44
+ let hit = pattern.exec(masked);
45
+ while (hit !== null) {
46
+ found.push(new Violation(
47
+ this.id,
48
+ 'warning',
49
+ `ambiguous "${hit[0]}", state exactly which option applies`,
50
+ new Region(uri, line, hit.index + 1)
51
+ ));
52
+ hit = pattern.exec(masked);
53
+ }
54
+ return found;
55
+ }
56
+ }
57
+
58
+ module.exports = AmbiguousOr;
@@ -0,0 +1,50 @@
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
+ * Budget.
13
+ *
14
+ * Demands that a whole manifesto stay short enough to stay readable,
15
+ * holding no more instructions than the cap allows. Counts every prose
16
+ * line plus every bullet item across the file and complains once with a
17
+ * single file-level violation when the total exceeds the cap.
18
+ *
19
+ * The check is standalone and deterministic, so prompt() returns an
20
+ * empty string and the AI oracle never re-checks this rule.
21
+ */
22
+ class Budget {
23
+ constructor(cap) {
24
+ this.id = 'budget';
25
+ this.cap = cap;
26
+ }
27
+ prompt() {
28
+ return '';
29
+ }
30
+ violations(document) {
31
+ const count = document.walk({
32
+ header: () => [],
33
+ prose: () => [1],
34
+ snippet: () => [],
35
+ bullets: () => [],
36
+ frontmatter: () => []
37
+ }).length;
38
+ if (count <= this.cap) {
39
+ return [];
40
+ }
41
+ return [new Violation(
42
+ this.id,
43
+ 'error',
44
+ `file holds ${count} instructions, budget ${this.cap}, split the manifesto`,
45
+ new Region(document.uri(), 1, 1)
46
+ )];
47
+ }
48
+ }
49
+
50
+ module.exports = Budget;
@@ -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
+ * Concise.
13
+ *
14
+ * Bounds a manifesto by structure, not only by token volume. Models read
15
+ * the start and end of a long context and skip the middle, so a manifesto
16
+ * that runs past a line budget silently buries its middle instructions.
17
+ * Counts physical lines and warns once the file crosses a configurable
18
+ * ceiling, recommending a split into referenced detail files in the
19
+ * spirit of progressive disclosure. This is distinct from token-count: it
20
+ * measures structure and position risk, not raw token volume. Its prompt
21
+ * hands the deeper split judgement to the AI oracle.
22
+ */
23
+ class Concise {
24
+ constructor(max) {
25
+ this.id = 'concise';
26
+ this.max = max;
27
+ }
28
+ prompt() {
29
+ return `${this.id}: flag a manifesto so long its middle instructions risk being lost, and recommend splitting detail into referenced files`;
30
+ }
31
+ violations(document) {
32
+ const lines = document.text().split('\n');
33
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
34
+ lines.pop();
35
+ }
36
+ if (lines.length <= this.max) {
37
+ return [];
38
+ }
39
+ return [new Violation(
40
+ this.id,
41
+ 'warning',
42
+ `file too long (${lines.length} lines), split detail into referenced files`,
43
+ new Region(document.uri(), this.max + 1, 1)
44
+ )];
45
+ }
46
+ }
47
+
48
+ module.exports = Concise;