@jpeakai/prose-gates 0.1.1 → 0.2.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 +37 -4
- package/dist/bin.js +8472 -539
- package/dist/index.js +8500 -540
- package/dist/testing.js +18321 -0
- package/examples/plugin-skills/README.md +60 -0
- package/examples/plugin-skills/index.mjs +14 -0
- package/examples/plugin-skills/package.json +12 -0
- package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
- package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
- package/examples/plugin-skills-typescript/README.md +42 -0
- package/examples/plugin-skills-typescript/index.ts +21 -0
- package/examples/plugin-skills-typescript/package.json +12 -0
- package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
- package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
- package/examples/plugin-skills-typescript/tsconfig.json +14 -0
- package/package.json +10 -3
- package/src/bin.ts +2 -2
- package/src/check.ts +40 -13
- package/src/cli.ts +197 -28
- package/src/config.ts +119 -0
- package/src/errors.ts +191 -0
- package/src/fix/engine.ts +31 -9
- package/src/fix/promote.ts +14 -2
- package/src/fix/verify.ts +29 -0
- package/src/index.ts +38 -2
- package/src/interpunct.ts +48 -0
- package/src/model.ts +129 -12
- package/src/plugins/contract.ts +41 -0
- package/src/plugins/discover.ts +140 -0
- package/src/plugins/index.ts +103 -0
- package/src/plugins/load.ts +234 -0
- package/src/plugins/runtime.ts +15 -0
- package/src/project.ts +81 -0
- package/src/query.ts +131 -0
- package/src/rules/index.ts +31 -12
- package/src/rules/list-comma-labelled-run.ts +101 -0
- package/src/rules/list-inline-enumeration-markers.ts +127 -0
- package/src/rules/list-interpunct-joined-run.ts +68 -0
- package/src/rules/list-semicolon-delimited-run.ts +60 -0
- package/src/rules/list-stacked-interpunct-runs.ts +80 -0
- package/src/rules/punctuation-em-dash-in-prose.ts +155 -0
- package/src/rules/punctuation-interpunct-in-prose.ts +52 -0
- package/src/rules/registry.ts +133 -0
- package/src/rules/sentence-one-per-line.ts +99 -0
- package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +22 -20
- package/src/rules/types.ts +70 -25
- package/src/testing.ts +60 -0
- package/src/text.ts +50 -0
- package/src/fix/spans.ts +0 -75
- package/src/rules/interpunct.ts +0 -19
- package/src/rules/pg001-wrap.ts +0 -79
- package/src/rules/pg003-semicolon-list.ts +0 -68
- package/src/rules/pg004-em-dash.ts +0 -117
- package/src/rules/pg005-interpunct.ts +0 -43
- package/src/rules/pg006-interpunct-run.ts +0 -84
- package/src/rules/pg007-inline-enum.ts +0 -119
- package/src/rules/pg008-labelled-run.ts +0 -94
- package/src/rules/pg009-stacked-runs.ts +0 -79
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# prose-gates-plugin-skills
|
|
2
|
+
|
|
3
|
+
A template plugin for the frontmatter of an agent skill.
|
|
4
|
+
It adds the category `frontmatter` and two rules, under the namespace `skills`.
|
|
5
|
+
[`examples/plugin-skills-typescript`](../plugin-skills-typescript) is the same plugin in TypeScript.
|
|
6
|
+
|
|
7
|
+
## The rules
|
|
8
|
+
|
|
9
|
+
| Rule | What it does | Fixer |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `skills/frontmatter-description-word-budget` | Reports a sentence over the word budget in any string whose nearest key is `description` | None, because shortening a sentence changes its words |
|
|
12
|
+
| `skills/frontmatter-description-multiline-string` | Reports a multi-sentence description written on one line | Rewrites it as a folded block scalar, one sentence per line |
|
|
13
|
+
|
|
14
|
+
Both read the whole YAML document through `doc.frontmatter.scalars`.
|
|
15
|
+
A `description` nested under `metadata`, or inside a list, is checked the same as a top-level one.
|
|
16
|
+
Set the `keys` option to look at other keys.
|
|
17
|
+
|
|
18
|
+
## The fixer
|
|
19
|
+
|
|
20
|
+
The multiline rule is the worked example of a fixer.
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
description: Use this skill to format reports. It also checks tables.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
becomes
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
description: >-
|
|
30
|
+
Use this skill to format reports.
|
|
31
|
+
It also checks tables.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The edit changes how the value is written and nothing it means.
|
|
35
|
+
It declares that with the `same-frontmatter-data` expectation, so the engine parses the YAML before and after and refuses the edit unless both give identical data.
|
|
36
|
+
The fixer skips a value that is already a block, that sits on the line below its key, or that has a comment after it, because each of those would change the data.
|
|
37
|
+
|
|
38
|
+
## Use it as a local rule
|
|
39
|
+
|
|
40
|
+
Copy a file from `rules/` to `.prose-gates/rules/` in your project.
|
|
41
|
+
It loads on its own, and its id is `local/` followed by the file name.
|
|
42
|
+
A rule file exports `categories` and a default rule, so it needs nothing else.
|
|
43
|
+
|
|
44
|
+
## Use it as a package
|
|
45
|
+
|
|
46
|
+
Publish this folder as `prose-gates-plugin-skills`, or copy it into your own package.
|
|
47
|
+
List it in `devDependencies` and it loads on its own, under the ids `skills/...`.
|
|
48
|
+
|
|
49
|
+
## Configure it
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"rules": {
|
|
54
|
+
"skills/frontmatter-description-word-budget": ["error", { "keys": ["description", "summary"], "maxWords": 40 }],
|
|
55
|
+
"skills/frontmatter-description-multiline-string": ["error", { "keys": ["description"] }]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
[docs/plugins.md](../../docs/plugins.md) explains every part of the contract.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The plugin entry: a name, a namespace, the contract version, the categories
|
|
2
|
+
// it adds and its rules. Each rule lives in rules/ so it can also be copied into
|
|
3
|
+
// a project as a local rule.
|
|
4
|
+
|
|
5
|
+
import multiline, { categories } from "./rules/frontmatter-description-multiline-string.mjs";
|
|
6
|
+
import wordBudget from "./rules/frontmatter-description-word-budget.mjs";
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: { name: "skills", namespace: "skills", apiVersion: 1, categories },
|
|
10
|
+
rules: {
|
|
11
|
+
"frontmatter-description-multiline-string": multiline,
|
|
12
|
+
"frontmatter-description-word-budget": wordBudget,
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prose-gates-plugin-skills",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Template plugin: holds frontmatter descriptions of agent skills to a sentence budget",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.mjs",
|
|
8
|
+
"keywords": ["prose-gates-plugin"],
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@jpeakai/prose-gates": ">=0.2.0"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// frontmatter-description-multiline-string: a description with several
|
|
2
|
+
// sentences written on one line. The fixer rewrites it as a folded block scalar,
|
|
3
|
+
// one sentence per line, which YAML reads back as the same string.
|
|
4
|
+
//
|
|
5
|
+
// description: First sentence. Second sentence.
|
|
6
|
+
//
|
|
7
|
+
// becomes
|
|
8
|
+
//
|
|
9
|
+
// description: >-
|
|
10
|
+
// First sentence.
|
|
11
|
+
// Second sentence.
|
|
12
|
+
//
|
|
13
|
+
// This is the worked example of a fixer. It proposes an edit that changes how the
|
|
14
|
+
// value is written and nothing it means, and it declares that with the
|
|
15
|
+
// `same-frontmatter-data` expectation, so the engine parses the YAML before and
|
|
16
|
+
// after and refuses the edit unless both give identical data.
|
|
17
|
+
|
|
18
|
+
export const categories = {
|
|
19
|
+
frontmatter: "Keys in the leading metadata block of a file",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The scalars this rule would rewrite: a single-line plain or quoted string, on
|
|
23
|
+
// the same line as its key, with two or more sentences, and no comment after it.
|
|
24
|
+
// Anything else is left alone, and the finding is not raised for it.
|
|
25
|
+
const candidates = ({ doc, options, helpers }) => {
|
|
26
|
+
const frontmatter = doc.frontmatter;
|
|
27
|
+
if (!frontmatter) return [];
|
|
28
|
+
const keys = options.keys ?? ["description"];
|
|
29
|
+
return frontmatter.scalars.filter((scalar) => {
|
|
30
|
+
if (scalar.key === null || !keys.includes(scalar.key)) return false;
|
|
31
|
+
if (scalar.style === "literal" || scalar.style === "folded") return false;
|
|
32
|
+
if (scalar.keyLine !== scalar.line || scalar.value.includes("\n")) return false;
|
|
33
|
+
if (helpers.sentences(scalar.value).length < 2) return false;
|
|
34
|
+
// A comment after the value would become part of the block scalar.
|
|
35
|
+
const lineEnd = doc.src.indexOf("\n", scalar.end);
|
|
36
|
+
const rest = doc.src.slice(scalar.end, lineEnd === -1 ? undefined : lineEnd);
|
|
37
|
+
return rest.trim() === "";
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
category: "frontmatter",
|
|
43
|
+
summary: "multi-sentence frontmatter value on one line; write it as a folded block",
|
|
44
|
+
options: { keys: "string[]" },
|
|
45
|
+
check(ctx) {
|
|
46
|
+
return candidates(ctx).map((scalar) => ({
|
|
47
|
+
line: scalar.line,
|
|
48
|
+
message: `${scalar.path.join(".")}: ${ctx.helpers.sentences(scalar.value).length} sentences on one line; write it as a folded block scalar`,
|
|
49
|
+
}));
|
|
50
|
+
},
|
|
51
|
+
fix(ctx) {
|
|
52
|
+
return candidates(ctx).map((scalar) => {
|
|
53
|
+
const pad = " ".repeat(scalar.indent + 2);
|
|
54
|
+
const lines = ctx.helpers.sentences(scalar.value).map((sentence) => `${pad}${sentence}`);
|
|
55
|
+
return {
|
|
56
|
+
start: scalar.start,
|
|
57
|
+
end: scalar.end,
|
|
58
|
+
text: `>-\n${lines.join("\n")}`,
|
|
59
|
+
expect: { kind: "same-frontmatter-data" },
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// frontmatter-description-word-budget: a sentence in a frontmatter value that
|
|
2
|
+
// runs past the word budget. It reads every string whose nearest key is in
|
|
3
|
+
// `keys`, which default to `description`, at any depth, so it covers the
|
|
4
|
+
// description of an agent skill and a description nested under a `metadata` key.
|
|
5
|
+
// It is a plain module so the same file works as a local rule in
|
|
6
|
+
// .prose-gates/rules and inside a packaged plugin.
|
|
7
|
+
//
|
|
8
|
+
// It never fixes: shortening a sentence changes its words.
|
|
9
|
+
|
|
10
|
+
export const categories = {
|
|
11
|
+
frontmatter: "Keys in the leading metadata block of a file",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
category: "frontmatter",
|
|
16
|
+
summary: "sentence in a frontmatter value longer than the word budget",
|
|
17
|
+
options: { keys: "string[]", maxWords: "number" },
|
|
18
|
+
check({ doc, maxWords, options, helpers }) {
|
|
19
|
+
const keys = options.keys ?? ["description"];
|
|
20
|
+
const frontmatter = doc.frontmatter;
|
|
21
|
+
if (!frontmatter) return [];
|
|
22
|
+
// `scalars` walks the whole YAML document, so a nested key matches too.
|
|
23
|
+
return frontmatter.scalars
|
|
24
|
+
.filter((scalar) => scalar.key !== null && keys.includes(scalar.key))
|
|
25
|
+
.flatMap((scalar) =>
|
|
26
|
+
helpers
|
|
27
|
+
.sentences(scalar.value)
|
|
28
|
+
.filter((sentence) => helpers.words(sentence) > maxWords)
|
|
29
|
+
.map((sentence) => ({
|
|
30
|
+
line: scalar.line,
|
|
31
|
+
message: `${scalar.path.join(".")}: ${helpers.lengthMessage(sentence, maxWords)}`,
|
|
32
|
+
})),
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# prose-gates-plugin-skills-typescript
|
|
2
|
+
|
|
3
|
+
The [`plugin-skills`](../plugin-skills) example, written in TypeScript.
|
|
4
|
+
It has the same two rules and behaves the same, under the namespace `skills-ts`.
|
|
5
|
+
|
|
6
|
+
| Rule | What it does | Fixer |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `skills-ts/frontmatter-description-word-budget` | Reports a sentence over the word budget in any `description`, at any depth | None |
|
|
9
|
+
| `skills-ts/frontmatter-description-multiline-string` | Reports a multi-sentence description on one line | Rewrites it as a folded block scalar |
|
|
10
|
+
|
|
11
|
+
## Types
|
|
12
|
+
|
|
13
|
+
Every type a rule needs comes from `@jpeakai/prose-gates`.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import type { FrontmatterScalar, PluginEdit, PluginFinding, PluginRule } from "@jpeakai/prose-gates";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A rule is typed `PluginRule`, so `check` returns `PluginFinding[]` and `fix` returns `PluginEdit[]`.
|
|
20
|
+
The engine adds the rule id and the file, so neither type carries them.
|
|
21
|
+
The repo type-checks this folder with `strict` on, so the example cannot drift from the API.
|
|
22
|
+
|
|
23
|
+
## Running it
|
|
24
|
+
|
|
25
|
+
TypeScript is imported as it is, so the runtime must be able to import it.
|
|
26
|
+
|
|
27
|
+
| How you load it | Runtime that works |
|
|
28
|
+
|---|---|
|
|
29
|
+
| A local rule in `.prose-gates/rules/*.ts` | Bun, or Node 22.18 or newer |
|
|
30
|
+
| A package in `node_modules` | Bun, or any runtime once it is compiled to JavaScript |
|
|
31
|
+
|
|
32
|
+
Node does not strip types from a file inside `node_modules`.
|
|
33
|
+
Publish a compiled build as the package's `main`, and keep the `.ts` source for local rules.
|
|
34
|
+
`bunx --bun @jpeakai/prose-gates` runs everything under Bun.
|
|
35
|
+
|
|
36
|
+
Write erasable syntax only, with no enums and no parameter properties, so Node can strip it.
|
|
37
|
+
|
|
38
|
+
## The fixer
|
|
39
|
+
|
|
40
|
+
The multiline rule rewrites a one-line, multi-sentence description as a folded block.
|
|
41
|
+
It declares `{ kind: "same-frontmatter-data" }`, so the engine parses the YAML before and after and refuses the edit unless both give identical data.
|
|
42
|
+
[docs/plugins.md](../../docs/plugins.md) explains the expectation and the frontmatter view.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The plugin entry, in TypeScript: a name, a namespace, the contract version, the
|
|
2
|
+
// categories it adds and its rules. Each rule lives in rules/ so it can also be
|
|
3
|
+
// copied into a project as a local rule.
|
|
4
|
+
//
|
|
5
|
+
// Node strips types only outside node_modules, so a package like this one loads
|
|
6
|
+
// under Bun, or after it is compiled. A local rule in .prose-gates/rules loads
|
|
7
|
+
// under Bun and under Node 22.18 or newer.
|
|
8
|
+
|
|
9
|
+
import type { Plugin } from "@jpeakai/prose-gates";
|
|
10
|
+
import multiline, { categories } from "./rules/frontmatter-description-multiline-string.ts";
|
|
11
|
+
import wordBudget from "./rules/frontmatter-description-word-budget.ts";
|
|
12
|
+
|
|
13
|
+
const plugin: Plugin = {
|
|
14
|
+
meta: { name: "skills-ts", namespace: "skills-ts", apiVersion: 1, categories },
|
|
15
|
+
rules: {
|
|
16
|
+
"frontmatter-description-multiline-string": multiline,
|
|
17
|
+
"frontmatter-description-word-budget": wordBudget,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default plugin;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prose-gates-plugin-skills-typescript",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Template plugin in TypeScript: holds the frontmatter of agent skills to a sentence budget and rewrites long descriptions as folded blocks",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.ts",
|
|
8
|
+
"keywords": ["prose-gates-plugin"],
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@jpeakai/prose-gates": ">=0.2.0"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// frontmatter-description-multiline-string, written in TypeScript. A description
|
|
2
|
+
// with several sentences written on one line. The fixer rewrites it as a folded
|
|
3
|
+
// block scalar, one sentence per line, which YAML reads back as the same string.
|
|
4
|
+
//
|
|
5
|
+
// description: First sentence. Second sentence.
|
|
6
|
+
//
|
|
7
|
+
// becomes
|
|
8
|
+
//
|
|
9
|
+
// description: >-
|
|
10
|
+
// First sentence.
|
|
11
|
+
// Second sentence.
|
|
12
|
+
//
|
|
13
|
+
// The edit changes how the value is written and nothing it means, and it says so
|
|
14
|
+
// with the `same-frontmatter-data` expectation. The engine parses the YAML before
|
|
15
|
+
// and after and refuses the edit unless both give identical data.
|
|
16
|
+
|
|
17
|
+
import type { CheckContext, FixContext, FrontmatterScalar, PluginEdit, PluginFinding, PluginRule } from "@jpeakai/prose-gates";
|
|
18
|
+
|
|
19
|
+
export const categories: Record<string, string> = {
|
|
20
|
+
frontmatter: "Keys in the leading metadata block of a file",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Context = Pick<CheckContext | FixContext, "doc" | "options" | "helpers">;
|
|
24
|
+
|
|
25
|
+
// The scalars this rule would rewrite: a single-line plain or quoted string, on
|
|
26
|
+
// the same line as its key, with two or more sentences, and no comment after it.
|
|
27
|
+
// Anything else is left alone, and no finding is raised for it.
|
|
28
|
+
const candidates = ({ doc, options, helpers }: Context): FrontmatterScalar[] => {
|
|
29
|
+
const frontmatter = doc.frontmatter;
|
|
30
|
+
if (!frontmatter) return [];
|
|
31
|
+
const keys = (options.keys as string[] | undefined) ?? ["description"];
|
|
32
|
+
return frontmatter.scalars.filter((scalar) => {
|
|
33
|
+
if (scalar.key === null || !keys.includes(scalar.key)) return false;
|
|
34
|
+
if (scalar.style === "literal" || scalar.style === "folded") return false;
|
|
35
|
+
if (scalar.keyLine !== scalar.line || scalar.value.includes("\n")) return false;
|
|
36
|
+
if (helpers.sentences(scalar.value).length < 2) return false;
|
|
37
|
+
// A comment after the value would become part of the block scalar.
|
|
38
|
+
const lineEnd = doc.src.indexOf("\n", scalar.end);
|
|
39
|
+
const rest = doc.src.slice(scalar.end, lineEnd === -1 ? undefined : lineEnd);
|
|
40
|
+
return rest.trim() === "";
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const rule: PluginRule = {
|
|
45
|
+
category: "frontmatter",
|
|
46
|
+
summary: "multi-sentence frontmatter value on one line; write it as a folded block",
|
|
47
|
+
options: { keys: "string[]" },
|
|
48
|
+
check(ctx): PluginFinding[] {
|
|
49
|
+
return candidates(ctx).map((scalar) => ({
|
|
50
|
+
line: scalar.line,
|
|
51
|
+
message: `${scalar.path.join(".")}: ${ctx.helpers.sentences(scalar.value).length} sentences on one line; write it as a folded block scalar`,
|
|
52
|
+
}));
|
|
53
|
+
},
|
|
54
|
+
fix(ctx): PluginEdit[] {
|
|
55
|
+
return candidates(ctx).map((scalar) => {
|
|
56
|
+
const pad = " ".repeat(scalar.indent + 2);
|
|
57
|
+
const lines = ctx.helpers.sentences(scalar.value).map((sentence) => `${pad}${sentence}`);
|
|
58
|
+
return {
|
|
59
|
+
start: scalar.start,
|
|
60
|
+
end: scalar.end,
|
|
61
|
+
text: `>-\n${lines.join("\n")}`,
|
|
62
|
+
expect: { kind: "same-frontmatter-data" },
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default rule;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// frontmatter-description-word-budget, written in TypeScript. A sentence in a
|
|
2
|
+
// frontmatter value that runs past the word budget, for any string whose nearest
|
|
3
|
+
// key is in `keys` (default `description`), at any depth.
|
|
4
|
+
//
|
|
5
|
+
// It never fixes: shortening a sentence changes its words.
|
|
6
|
+
//
|
|
7
|
+
// The file is a plain module, so it works as a local rule in .prose-gates/rules
|
|
8
|
+
// and inside the packaged plugin. The type import is erased at run time, so the
|
|
9
|
+
// runtime only has to be able to import TypeScript.
|
|
10
|
+
|
|
11
|
+
import type { PluginFinding, PluginRule } from "@jpeakai/prose-gates";
|
|
12
|
+
|
|
13
|
+
export const categories: Record<string, string> = {
|
|
14
|
+
frontmatter: "Keys in the leading metadata block of a file",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const rule: PluginRule = {
|
|
18
|
+
category: "frontmatter",
|
|
19
|
+
summary: "sentence in a frontmatter value longer than the word budget",
|
|
20
|
+
options: { keys: "string[]", maxWords: "number" },
|
|
21
|
+
check({ doc, maxWords, options, helpers }): PluginFinding[] {
|
|
22
|
+
const keys = (options.keys as string[] | undefined) ?? ["description"];
|
|
23
|
+
const frontmatter = doc.frontmatter;
|
|
24
|
+
if (!frontmatter) return [];
|
|
25
|
+
// `scalars` walks the whole YAML document, so a nested key matches too.
|
|
26
|
+
return frontmatter.scalars
|
|
27
|
+
.filter((scalar) => scalar.key !== null && keys.includes(scalar.key))
|
|
28
|
+
.flatMap((scalar) =>
|
|
29
|
+
helpers
|
|
30
|
+
.sentences(scalar.value)
|
|
31
|
+
.filter((sentence) => helpers.words(sentence) > maxWords)
|
|
32
|
+
.map((sentence) => ({
|
|
33
|
+
line: scalar.line,
|
|
34
|
+
message: `${scalar.path.join(".")}: ${helpers.lengthMessage(sentence, maxWords)}`,
|
|
35
|
+
})),
|
|
36
|
+
);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default rule;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noUncheckedIndexedAccess": true,
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"verbatimModuleSyntax": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["index.ts", "rules/*.ts"]
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jpeakai/prose-gates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic prose gates for markdown documentation",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
"bun": "./src/index.ts",
|
|
33
33
|
"types": "./src/index.ts",
|
|
34
34
|
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./testing": {
|
|
37
|
+
"bun": "./src/testing.ts",
|
|
38
|
+
"types": "./src/testing.ts",
|
|
39
|
+
"default": "./dist/testing.js"
|
|
35
40
|
}
|
|
36
41
|
},
|
|
37
42
|
"engines": {
|
|
@@ -39,7 +44,8 @@
|
|
|
39
44
|
},
|
|
40
45
|
"files": [
|
|
41
46
|
"src",
|
|
42
|
-
"dist"
|
|
47
|
+
"dist",
|
|
48
|
+
"examples"
|
|
43
49
|
],
|
|
44
50
|
"dependencies": {
|
|
45
51
|
"mdast-util-from-markdown": "^2.0.3",
|
|
@@ -47,7 +53,8 @@
|
|
|
47
53
|
"mdast-util-gfm": "^3.1.0",
|
|
48
54
|
"micromark-extension-frontmatter": "^2.0.0",
|
|
49
55
|
"micromark-extension-gfm": "^3.0.0",
|
|
50
|
-
"unist-util-visit-parents": "^6.0.2"
|
|
56
|
+
"unist-util-visit-parents": "^6.0.2",
|
|
57
|
+
"yaml": "^2.9.1"
|
|
51
58
|
},
|
|
52
59
|
"devDependencies": {
|
|
53
60
|
"@biomejs/biome": "^2",
|
package/src/bin.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { main } from "./cli.ts";
|
|
2
|
+
import { describeError, main } from "./cli.ts";
|
|
3
3
|
|
|
4
4
|
main().then(
|
|
5
5
|
(code) => process.exit(code),
|
|
6
6
|
(err: unknown) => {
|
|
7
|
-
console.error(
|
|
7
|
+
console.error(describeError(err));
|
|
8
8
|
process.exit(1);
|
|
9
9
|
},
|
|
10
10
|
);
|
package/src/check.ts
CHANGED
|
@@ -1,24 +1,51 @@
|
|
|
1
|
-
// Checking: build the model once, run every rule over it, and audit
|
|
1
|
+
// Checking: build the model once, run every active rule over it, and audit
|
|
2
2
|
// embedded markdown fences as documents of their own.
|
|
3
|
+
//
|
|
4
|
+
// Nothing is modified in check mode and no rule reads another's result, so every
|
|
5
|
+
// rule is started at once and the engine waits for all of them. A rule may be
|
|
6
|
+
// synchronous or return a promise, so concurrency is as good as parallelism
|
|
7
|
+
// here: a rule that waits on a tool or a service overlaps with the others.
|
|
3
8
|
|
|
4
9
|
import { buildDocModel, type DocModel } from "./model.ts";
|
|
5
|
-
import {
|
|
6
|
-
import { interpunctRuns } from "./rules/interpunct.ts";
|
|
10
|
+
import { BUILTIN, type Registry, ruleContext, TEXT_HELPERS } from "./rules/registry.ts";
|
|
7
11
|
import type { Finding } from "./rules/types.ts";
|
|
8
12
|
|
|
9
13
|
export const DEFAULT_MAX_WORDS = 25;
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
// The sentence budget of a run: the flag wins, then the rule's own option,
|
|
16
|
+
// then the default.
|
|
17
|
+
const budgetFor = (flag: number | undefined, option: unknown): number =>
|
|
18
|
+
flag ?? (typeof option === "number" ? option : DEFAULT_MAX_WORDS);
|
|
19
|
+
|
|
20
|
+
export const checkModel = async (
|
|
21
|
+
doc: DocModel,
|
|
22
|
+
file: string,
|
|
23
|
+
maxWords: number | undefined,
|
|
24
|
+
registry: Registry = BUILTIN,
|
|
25
|
+
): Promise<Finding[]> => {
|
|
26
|
+
const shared = ruleContext(doc, registry);
|
|
27
|
+
const perRule = registry.rules.map(async (rule) => {
|
|
28
|
+
const options = registry.options.get(rule.id) ?? {};
|
|
29
|
+
return rule.check({
|
|
30
|
+
...shared,
|
|
31
|
+
file,
|
|
32
|
+
maxWords: budgetFor(maxWords, options.maxWords),
|
|
33
|
+
options,
|
|
34
|
+
helpers: TEXT_HELPERS,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
14
37
|
// The fence body starts one line below the opening fence.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
38
|
+
const perFence = doc.fences.map(async (fence) => {
|
|
39
|
+
const inner = await checkModel(buildDocModel(fence.value), file, maxWords, registry);
|
|
40
|
+
return inner.map((f) => ({ ...f, line: fence.line + f.line }));
|
|
41
|
+
});
|
|
42
|
+
const findings = (await Promise.all([...perRule, ...perFence])).flat();
|
|
20
43
|
return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
|
|
21
44
|
};
|
|
22
45
|
|
|
23
|
-
export const checkMarkdown = (
|
|
24
|
-
|
|
46
|
+
export const checkMarkdown = (
|
|
47
|
+
src: string,
|
|
48
|
+
file: string,
|
|
49
|
+
maxWords?: number,
|
|
50
|
+
registry: Registry = BUILTIN,
|
|
51
|
+
): Promise<Finding[]> => checkModel(buildDocModel(src), file, maxWords, registry);
|