gcf-aif-ext 0.5.1 → 0.5.2
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 +5 -0
- package/extension.json +1 -1
- package/lib/systems-doc.js +6 -2
- package/node_modules/cnlp-kit/CHANGELOG.md +207 -0
- package/node_modules/cnlp-kit/README.md +40 -3
- package/node_modules/cnlp-kit/bin/cnlp.js +71 -10
- package/node_modules/cnlp-kit/cnlp-format.md +36 -3
- package/node_modules/cnlp-kit/package.json +1 -1
- package/node_modules/cnlp-kit/profiles/docset.md +103 -103
- package/node_modules/cnlp-kit/skills/cnlp-docset/SKILL.md +6 -4
- package/node_modules/cnlp-kit/src/cnlp.js +71 -9
- package/node_modules/cnlp-kit/src/config.js +233 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -50,6 +50,11 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
|
|
|
50
50
|
|
|
51
51
|
On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
|
|
52
52
|
|
|
53
|
+
### 0.5.2
|
|
54
|
+
|
|
55
|
+
- Bundles `cnlp-kit` 3.9, so `gcf design check` and the commit gate accept a bullet written in Cyrillic alone.
|
|
56
|
+
- With `cnlp-kit` 3.9 or later, the `AGENTS.md` block between `<!-- gcf:start -->` and `<!-- gcf:end -->` is a marked region that the project's guide profile does not shape: the `custom_sections` entries 0.5.1 asked for are no longer needed. Update the `cnlp-kit` your project runs `npx cnlp check` with.
|
|
57
|
+
|
|
53
58
|
### 0.5.1
|
|
54
59
|
|
|
55
60
|
- A provenance record `assumed: q-NNN` must name a record still in `gcf/open-questions.md` (M9). A D2 record lives until its `until:` fires; once the human confirms the value, cite `answer: q-NNN` instead and remove it. After updating, run `ai-factory gcf design check`: every citation of a removed question blocks the commit gate until it is rewritten.
|
package/extension.json
CHANGED
package/lib/systems-doc.js
CHANGED
|
@@ -9,11 +9,15 @@ import { scalarIn } from './design.js';
|
|
|
9
9
|
|
|
10
10
|
const NONE_LINE = '- none: no item of this subsystem has collapsed yet';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The raw lines of a `key:` block. `prose`, not `lines`: cnlp-kit 3.9 blanks HTML comments in
|
|
14
|
+
* `lines`, and upsertBlock rewrites the block from these — a `gcf:allow` pragma must survive.
|
|
15
|
+
*/
|
|
12
16
|
export function blockLines(body, key) {
|
|
13
|
-
const {
|
|
17
|
+
const { prose, sections } = parseBlocks(body);
|
|
14
18
|
const at = sections.find((s) => s.key === key);
|
|
15
19
|
|
|
16
|
-
return at ? itemsOf(
|
|
20
|
+
return at ? itemsOf(prose, at.line).map((i) => i.text) : [];
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/**
|
|
@@ -4,6 +4,213 @@ The format has its own version from here on. It used to be stamped with the rele
|
|
|
4
4
|
an ADR extension, which made a breaking change to the format invisible inside that project's
|
|
5
5
|
patch release.
|
|
6
6
|
|
|
7
|
+
## 3.9.1
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`cnlp --version`** writes the version of the installed package, alone on 1 line, and exits 0.
|
|
12
|
+
Before it the only answer was `npm ls cnlp-kit`, and `--version` printed the usage and exited 1.
|
|
13
|
+
|
|
14
|
+
## 3.9.0
|
|
15
|
+
|
|
16
|
+
An agent file that several tools write into could not pass the check. Each tool keeps its own block
|
|
17
|
+
between `<!-- <name>:start -->` and `<!-- <name>:end -->`, and `cnlp check` read the marker as an
|
|
18
|
+
item of the block above it and the tool's keys as unknown blocks of the file. Neither the tool nor
|
|
19
|
+
the project could fix that honestly: moving a marker moves the issue to another block, and naming a
|
|
20
|
+
tool's keys in `custom_sections:` breaks §7, which keeps that block for content that is not a rule.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **§3: an HTML comment is markup, not structure.** A line that is 1 whole comment, and every line
|
|
25
|
+
of a comment that runs over several, is no item of the block above it and no section key. A line
|
|
26
|
+
carrying text beside a comment is still read, a comment that never closes hides nothing, and the
|
|
27
|
+
§4 and §5 line rules still read comment text, because an agent reads the raw file.
|
|
28
|
+
- **§3: a marked region belongs to the tool that wrote it.** The lines between
|
|
29
|
+
`<!-- <name>:start -->` and `<!-- <name>:end -->` are not shaped by the file's profile: no key in
|
|
30
|
+
them is unknown, repeated or out of order, no heading in them is unknown, and nothing in them
|
|
31
|
+
supplies a block the profile requires. §4 and §5 still hold on every line. An end closes the
|
|
32
|
+
latest start of its own name; a start with no end marks nothing, so it cannot exempt the rest of
|
|
33
|
+
a file.
|
|
34
|
+
- **`cnlp check` lists every region it did not shape**, by file, line range and name, the way it
|
|
35
|
+
lists the files the lexicon did not reach: a region is an exemption any author can write.
|
|
36
|
+
- **A page reference inside a region is not resolved**, and does not reach a page. It belongs to
|
|
37
|
+
the tool's own corpus, not to this one.
|
|
38
|
+
- **`parseBlocks` changed what it returns.** `lines`, which `itemsOf` and every structural check
|
|
39
|
+
read, now blanks comments and regions as well as fences. `prose` is new: it blanks fences only,
|
|
40
|
+
and the lexicon reads it. `regions` is new: `{ name, line, end }` for each marked region.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- **A reported line is a line of the file.** `parseBlocks` stripped the frontmatter, so every line
|
|
45
|
+
number in a document carrying one counted from the end of it: an issue on line 13 of a skill read
|
|
46
|
+
as line 10. The frontmatter is now blanked in place, so `<file>:<line>` opens the line it names.
|
|
47
|
+
|
|
48
|
+
## 3.8.1
|
|
49
|
+
|
|
50
|
+
2 things a consumer of a produced set ran into. A registry that keeps a pre-release as
|
|
51
|
+
`2.0.0-[rc]` could not check it by path, and `identity:` `version:` had no stated shape, so the
|
|
52
|
+
kit's own skill wrote `0.9.2, read at commit a741e91` where a tool comparing versions expects `0.9.2`.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **`cnlp check <dir>` reads a set whose directory names carry `[`, `*` or `?`.** `configForPath`
|
|
57
|
+
put the directory names into the corpus glob as they were, so `2.0.0-[rc]` read as a character
|
|
58
|
+
class that matches no directory, and the set was reported as holding no document. Each name now
|
|
59
|
+
goes in as a glob matching only itself. A `{` still opens a brace set: `fs.glob` offers no escape
|
|
60
|
+
for it, and a set directory named with braces needs a config.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- **`profiles/docset.md`: `version:` opens with the version alone.** The `identity:` note says so,
|
|
65
|
+
and why: a tool comparing the version reads it up to the first comma. Anything after that comma
|
|
66
|
+
stays a note for a reader, which is the shape `examples/dragonecs-unity` already has.
|
|
67
|
+
- **`skills/cnlp-docset/SKILL.md`, step 4:** it records the version alone in `version:`, then a comma
|
|
68
|
+
and the revision, instead of leaving the 2 to be written in any order. An installed copy of the
|
|
69
|
+
skill reports as stale until `cnlp init` runs again.
|
|
70
|
+
|
|
71
|
+
## 3.8.0
|
|
72
|
+
|
|
73
|
+
A set that crosses into a companion could not be checked on its own. `configForPath(dir)` built a
|
|
74
|
+
corpus of the 1 set it was pointed at, so `cnlp check examples/dragonecs-unity` reported 14
|
|
75
|
+
references that resolve the moment both sets are read together, and a registry keeping sets as
|
|
76
|
+
`<set>/<version>/` had no root holding both at all.
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- **`configForPath(dir, { name, companions })`.** `name` names the checked set when its directory
|
|
81
|
+
does not; `companions` is a list of `{ name, dir }`, each a set whose pages resolve references and
|
|
82
|
+
whose files are never checked. The returned config always carries `companions` (absolute dirs,
|
|
83
|
+
`[]` when none) and `rename`, so a caller detects a kit that ignores the option by the field's
|
|
84
|
+
absence. A name that is empty or carries `:`, `/` or `\`, a companion with no `index.md`, a
|
|
85
|
+
companion named like the checked set or named twice, a companion that is the checked set's own
|
|
86
|
+
directory, and either option on a directory that is not 1 set each fail with the fix. The checked
|
|
87
|
+
directory is read by its spelling on disk, so a path typed in another case does not read the set
|
|
88
|
+
as its own sibling.
|
|
89
|
+
- **`cnlp check <dir> --name <set> --companion <set>=<dir>`.** `--companion` repeats and splits at
|
|
90
|
+
its first `=`. Each companion prints `resolving against <name> at <dir>`: a caller passing
|
|
91
|
+
companions detects an executable older than 3.8.0 by that line's absence, so its wording is kept
|
|
92
|
+
stable. Both flags fail with a config run, which already reads every set under its root, and in
|
|
93
|
+
the `--flag=value` form, which would otherwise be ignored and print no `resolving` line. A value
|
|
94
|
+
flag followed directly by another flag now reads as having no value, for every value flag.
|
|
95
|
+
- **`linkIssues(docs, docsets, { context, rename })` and `setSizes(docs, docsets, rename)`.** A
|
|
96
|
+
companion's pages resolve a `page:` or `by:` value, its name resolves a `docset:` value, and its
|
|
97
|
+
references count toward reach; its own references and its unreached pages are never reported,
|
|
98
|
+
and it is in no count. A call with 2
|
|
99
|
+
arguments behaves as before.
|
|
100
|
+
|
|
101
|
+
### Changed
|
|
102
|
+
|
|
103
|
+
- **A set checked by path resolves against the sets beside it.** When `<dir>` holds an `index.md`
|
|
104
|
+
and its name is the directory's name, every sibling directory holding an `index.md` joins as a
|
|
105
|
+
companion. `cnlp check <set>` and `configForPath(<set>)` report fewer issues where a sibling answers
|
|
106
|
+
a reference; `cnlp check examples/dragonecs-unity` and `examples/dragonecs` both conform now. An
|
|
107
|
+
explicit companion of the same name wins over a sibling.
|
|
108
|
+
- **`cnlp check <dir>` prints its `reading` line before the check runs**, with the `resolving` lines
|
|
109
|
+
after it, so a check that throws still says what it was reading.
|
|
110
|
+
- **`cnlp-format.md` §9** says a directory checked by path reaches the reference rules with no
|
|
111
|
+
config, and that a set checked on its own resolves against its companions.
|
|
112
|
+
- **`skills/cnlp-docset/SKILL.md`**: a new step 16 names the sets a crossing reaches that do not sit
|
|
113
|
+
beside the output directory, and step 17 passes each as `--companion`. The old steps 16 to 19 are
|
|
114
|
+
17 to 20. An installed copy of the skill reports as stale until `cnlp init` runs again.
|
|
115
|
+
|
|
116
|
+
## 3.7.0
|
|
117
|
+
|
|
118
|
+
The upgrade path reached the skills and nothing else. A project holds 2 more copies of what this
|
|
119
|
+
package declares — a local profile, and the block `cnlp init` prints for an agent file — and both
|
|
120
|
+
are files the project owns, so neither can be overwritten on an upgrade. They are reported instead.
|
|
121
|
+
|
|
122
|
+
### Added
|
|
123
|
+
|
|
124
|
+
- **`cnlp check` names a local profile the packaged one has outgrown.** `profileDrift` in
|
|
125
|
+
`src/config.js` reads the copy and the packaged profile of the same name and reports what the
|
|
126
|
+
package declares and the copy does not: a section, its form, a record key, a required block, a
|
|
127
|
+
heading, a frontmatter field, the mood. What a copy adds is the vocabulary it was copied for and
|
|
128
|
+
is never reported, so a filled `custom_sections:` stays silent. Until now `profileFor` preferred
|
|
129
|
+
the copy and said nothing about its age: a profile copied at 3.1 held the corpus to a declaration
|
|
130
|
+
3 versions old, and the run reported it conformant.
|
|
131
|
+
- **`cnlp check` names an agent file carrying an earlier copy of the pasted block.** The block is 1
|
|
132
|
+
value now — `AGENT_BLOCK` in `src/config.js` — read by the `init` that prints it and by the check
|
|
133
|
+
that watches it. `agentBlockDrift` reads `AGENTS.md` and `CLAUDE.md` at the project root and finds
|
|
134
|
+
the copy by its own opening bullet, the one naming `npx cnlp print format`. A file that never took
|
|
135
|
+
the block stays silent. It answers half of `docs/BACKLOG.md` item 9: the copy is watched, writing
|
|
136
|
+
it is still open.
|
|
137
|
+
|
|
138
|
+
Both are reports and not issues, like the stale-skill line: a copy the project owns is not a defect
|
|
139
|
+
in a document the corpus names, and neither decides the exit code.
|
|
140
|
+
|
|
141
|
+
## 3.6.0
|
|
142
|
+
|
|
143
|
+
A bug report from a project writing its corpus in Russian. The item rule read an ASCII letter, so
|
|
144
|
+
every bullet of every non-Latin script reported as an item carrying no word, and the fix is 1
|
|
145
|
+
character class. What the report turned up behind it is the release: the checker was answering
|
|
146
|
+
`conform` over a corpus half of §5 never touched.
|
|
147
|
+
|
|
148
|
+
### Fixed
|
|
149
|
+
|
|
150
|
+
- **An item states its claim in a letter of any script.** `§5`'s "the item carries no word" rule read
|
|
151
|
+
`/[a-z]/i`, which reports every bullet written in Cyrillic, Greek, Hebrew or a CJK script and
|
|
152
|
+
passes the moment the line names a Latin identifier. It reads `/\p{L}/u` now. What the rule was
|
|
153
|
+
written for — `- 4`, `- —`, `- ...`, the residue of a mechanical split — is rejected unchanged,
|
|
154
|
+
because a digit and a punctuation mark are letters in no script.
|
|
155
|
+
|
|
156
|
+
### Added
|
|
157
|
+
|
|
158
|
+
- **`cnlp check` names the files the §5 word rules did not reach.** Every word list in §5 — the deny
|
|
159
|
+
list, the spelled thresholds, the open limits, the comparatives, the light verbs, the passive — is
|
|
160
|
+
English. Against a body in another script they raise no false issue; they silently match nothing,
|
|
161
|
+
which made a run report a conformant corpus over rules that never ran. `lexiconApplies` in
|
|
162
|
+
`src/cnlp.js` decides it from the prose — the line with its section key, its heading marker and its
|
|
163
|
+
quoted spans taken off, so this format's own English vocabulary and a Latin identifier do not
|
|
164
|
+
count as English prose — and `bin/cnlp.js` prints the count and the paths. It is a report and not
|
|
165
|
+
an issue: nothing in such a file is wrong, and the exit code does not change.
|
|
166
|
+
|
|
167
|
+
### Changed
|
|
168
|
+
|
|
169
|
+
- **`§5` states that its word rules are English**, which rules still hold for a document in another
|
|
170
|
+
script — §3 block forms, §4 line rules, quoted text — and that the checker reports the coverage it
|
|
171
|
+
lost rather than staying silent about it.
|
|
172
|
+
|
|
173
|
+
## 3.5.0
|
|
174
|
+
|
|
175
|
+
The first release shaped by a measurement rather than by a reading. `docs/PROBE-behaviour.md` holds
|
|
176
|
+
the run: 4 arms, 14 tasks, 5 repetitions, against a falsifier committed before the first cell. A
|
|
177
|
+
document set halved the wrong-call rate against the library's own documentation, 7% against 16%, and
|
|
178
|
+
beat an arm carrying all 19 documentation pages and the whole declaration surface in context — 3.3
|
|
179
|
+
times the tokens for more wrong calls, not fewer.
|
|
180
|
+
|
|
181
|
+
Nothing here changes the standard or a checker rule. The run produced 1 loss and 1 cost, and each
|
|
182
|
+
gets the smallest answer that fits it.
|
|
183
|
+
|
|
184
|
+
The cost: a set is dearer per question than the documentation it replaces, 22172 tokens a call
|
|
185
|
+
against 19236, and the difference is the index. A reader loads the index to answer any question and
|
|
186
|
+
a page to answer 1, so those are different kinds of cost, and nothing in the kit made the split
|
|
187
|
+
visible to whoever was growing one of them.
|
|
188
|
+
|
|
189
|
+
The loss: on 1 task of 14 the set scored worse than the prose. The task wanted the `Scene` value a
|
|
190
|
+
load returned; the set documents the manager's lookup members in full, because `catalog:` is closed
|
|
191
|
+
and they are part of the library, and the agent went looking a scene up instead. A task page's
|
|
192
|
+
preference lost to an API page's completeness, and nothing ranks them. That is `docs/BACKLOG.md`
|
|
193
|
+
item 10, left open on purpose: 1 task in 1 set does not buy a vocabulary.
|
|
194
|
+
|
|
195
|
+
### Added
|
|
196
|
+
|
|
197
|
+
- **`cnlp check` reports each set's index bytes against its page bytes**, as
|
|
198
|
+
`<set>: index N B fixed, M page(s) K B on demand`. `checkProject` computes it from the documents
|
|
199
|
+
it has already read, so the report costs no second read, and `bin/cnlp.js` prints it. It is a
|
|
200
|
+
report and not a rule: the numbers say nothing about what a set may weigh.
|
|
201
|
+
- **A step in `skills/cnlp-docset/SKILL.md`** for the 1 rule the run turned up that no check reaches:
|
|
202
|
+
a member a task page steers a reader away from carries that in its own `pitfalls:`. Step 18; the
|
|
203
|
+
old step 18 is 19.
|
|
204
|
+
|
|
205
|
+
### Changed
|
|
206
|
+
|
|
207
|
+
- **`profiles/docset.md` `read_protocol:` states what the index costs.** The index is the cost of
|
|
208
|
+
every question and a page the cost of 1. It is a value rather than a rule, so it sits in the
|
|
209
|
+
profile, and §6 keeps it in exactly 1 note.
|
|
210
|
+
- **`docs/DOCSETS.md` carries what the probe measured about behaviour**, including the result the
|
|
211
|
+
totals hide: both reading arms averaged 4.4 turns, and on the task whose contract no documentation
|
|
212
|
+
page carries, the documentation arm read *more* than anywhere else and still failed every run.
|
|
213
|
+
|
|
7
214
|
## 3.4.1
|
|
8
215
|
|
|
9
216
|
A record-list of multi-key records read as a wall. The only boundary between 2 records was a line
|
|
@@ -46,6 +46,9 @@ Nothing else about your config moves on that second run.
|
|
|
46
46
|
config records, and installs for every agent when there is no config yet.
|
|
47
47
|
- `cnlp check` reports skills left behind by an upgrade, and its exit code stays the corpus's own
|
|
48
48
|
answer: a stale copy is not a defect in a document.
|
|
49
|
+
- **An upgrade reaches the skills and nothing else, because nothing else is the package's to
|
|
50
|
+
rewrite.** A local profile and the pasted agent-file block are files you own, so `cnlp check`
|
|
51
|
+
reports what each one is missing against the installed version and leaves both where they are.
|
|
49
52
|
|
|
50
53
|
On the first run `cnlp init` also looks at what the repository already has — `AGENTS.md`,
|
|
51
54
|
`CLAUDE.md`, `skills/*/SKILL.md`, `docs/adr/*.md`, `docs/references/*.md`, `docs/docsets/*/…` — and
|
|
@@ -66,7 +69,11 @@ you say otherwise. Edit the entries it wrote, or add your own:
|
|
|
66
69
|
|
|
67
70
|
`profiles` is optional and names a directory of your own profiles. A profile there resolves
|
|
68
71
|
**before** the packaged one of the same name, which is how a project adds its own vocabulary:
|
|
69
|
-
copy `profiles/skill.md` into it and fill `custom_sections:`.
|
|
72
|
+
copy `profiles/skill.md` into it and fill `custom_sections:`. The copy is yours and no command
|
|
73
|
+
here rewrites it, so `cnlp check` names what the packaged profile declares and the copy does not
|
|
74
|
+
— a section, a form, a record key, a required block — which is how an upgrade reaches a file it
|
|
75
|
+
must not overwrite. What the copy adds is the vocabulary it was copied for, and is never
|
|
76
|
+
reported.
|
|
70
77
|
|
|
71
78
|
`cnlp init` also prints the block to paste into `AGENTS.md`, `CLAUDE.md` or whatever file your
|
|
72
79
|
agent reads. Three lines, and they are the rules no skill announces: an installed skill states
|
|
@@ -94,9 +101,27 @@ set is a folder of markdown and nothing else — **no config file, no dependency
|
|
|
94
101
|
it** — and `cnlp check <dir>` reads it by path:
|
|
95
102
|
|
|
96
103
|
```sh
|
|
97
|
-
cnlp check ./nanoid-docset
|
|
104
|
+
cnlp check ./nanoid-docset
|
|
105
|
+
# 12 file(s) conform
|
|
106
|
+
# nanoid-docset: index 5603 B fixed, 11 page(s) 25778 B on demand
|
|
98
107
|
```
|
|
99
108
|
|
|
109
|
+
The 2 sizes are reported apart because they are different costs. A reader loads the index to answer
|
|
110
|
+
any question and a page to answer 1, so an index that grows is paid on every question.
|
|
111
|
+
|
|
112
|
+
**Checking 1 set of a pair.** A set that extends another names it in `companions:` or `overrides:`,
|
|
113
|
+
and its references cross into it. Checked by path, a set resolves those against the sets beside it,
|
|
114
|
+
so `cnlp check examples/dragonecs-unity` conforms on its own. A companion kept elsewhere is named,
|
|
115
|
+
and a set whose directory is a version rather than its name is named too:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
cnlp check docsets/dragonecs-unity/0.9.2 --name dragonecs-unity --companion dragonecs=docsets/dragonecs/1.1.17
|
|
119
|
+
# reading 4 entr(ies) under …/docsets
|
|
120
|
+
# resolving against dragonecs at …/docsets/dragonecs/1.1.17
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A companion is read to resolve a reference and never checked: its own defects are not this run's.
|
|
124
|
+
|
|
100
125
|
That is the producing side. **Adopting** a set is the other half and a separate act: a repository
|
|
101
126
|
that will hold one among its own documents runs `cnlp init`, adds the `docsets` key and the four
|
|
102
127
|
corpus entries shown under [Document sets](#document-sets), and checks it with everything else from
|
|
@@ -111,6 +136,10 @@ that states it. What the set rests on lands in `identity:` `derived_from:`, so a
|
|
|
111
136
|
page written from a signature from one written from prose, and the skill will not quietly write a
|
|
112
137
|
prose claim as though it came from a declaration.
|
|
113
138
|
|
|
139
|
+
`identity:` `version:` opens with the version alone, and the revision the pages were read at follows
|
|
140
|
+
a comma — `version: 0.9.2, read at commit a741e91`. A tool that compares the set against the version
|
|
141
|
+
a project resolves, such as a registry or a version-guard test, reads up to that first comma.
|
|
142
|
+
|
|
114
143
|
It builds in the order that keeps a half-finished set honest: the closed catalog of every public
|
|
115
144
|
capability first, every record at `status: source`, so the set itself says what has no page yet; then
|
|
116
145
|
the type names of one running example; then `canonical_use:`, one whole working program in the index;
|
|
@@ -122,11 +151,12 @@ other page reaches. `cnlp docs` is the single command you point your agent at.
|
|
|
122
151
|
| | |
|
|
123
152
|
|---|---|
|
|
124
153
|
| `cnlp init [<dir>] [--agents <a,b>]` | install or update: config, profiles directory, skills for the agents you pick |
|
|
125
|
-
| `cnlp check [<dir>] [--config <path>]` | hold every document to its profile: a directory read by path, or every corpus entry of the config. Exit non-zero on any issue |
|
|
154
|
+
| `cnlp check [<dir>] [--config <path>] [--name <set>] [--companion <set>=<dir>]` | hold every document to its profile: a directory read by path, or every corpus entry of the config. A set read by path resolves its references against the sets beside it and the companions you name. Reports each set's index bytes against its page bytes, names any file the §5 word rules did not reach and any marked region its profile did not shape, and names a local profile or a pasted agent-file block left behind by an upgrade. Exit non-zero on any issue |
|
|
126
155
|
| `cnlp docs [<set>]` | write every document set's index, in reading order |
|
|
127
156
|
| `cnlp print [<doc>] [--path]` | write the standard, or a profile, to stdout |
|
|
128
157
|
| `cnlp scaffold <profile> <data.json>` | write a document from data, in the blocks and forms the profile declares |
|
|
129
158
|
| `cnlp diff <profile> <doc> <data.json>` | report what moved between a document and a fresh read of its sources |
|
|
159
|
+
| `cnlp --version` | write the version of the installed package, alone on 1 line |
|
|
130
160
|
|
|
131
161
|
## The ownership rule
|
|
132
162
|
|
|
@@ -167,6 +197,13 @@ and the inventory step of `cnlp-migrate` is where it comes from. `adr` declares
|
|
|
167
197
|
block list and no custom section, because an ADR block outside that list is prose looking
|
|
168
198
|
for a home.
|
|
169
199
|
|
|
200
|
+
The §5 word rules are English — the deny list, the spelled thresholds, the open limits, the
|
|
201
|
+
comparatives, the light verbs, the passive are lists of English words. A document written in
|
|
202
|
+
another script matches none of them, so it is held to the rules that read shape — block forms,
|
|
203
|
+
line rules, quoted text — and `cnlp check` names it rather than reporting a corpus as conformant
|
|
204
|
+
against rules that never ran. The check reads script and not language, so a Latin-script document
|
|
205
|
+
in another language still reads as English to it.
|
|
206
|
+
|
|
170
207
|
Severity is the caller's. The checker returns issues; `cnlp check` treats every issue as an
|
|
171
208
|
error. A project that gates on a lifecycle status — a warning while a decision is a draft, an
|
|
172
209
|
error once it is accepted — applies that policy in its own validator, calling `bodyIssues`
|
|
@@ -9,16 +9,19 @@ import path from 'node:path';
|
|
|
9
9
|
import readline from 'node:readline';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { PACKAGE_ROOT, recordDrift, renderBody, resolveDoc } from '../src/cnlp.js';
|
|
12
|
-
import { AGENTS, CONFIG_NAME, STAMP_NAME, checkProject, configForPath, docsetsOf, findConfig, profileFor, proposeCorpus, readConfig, readingOrder, skillDrift, skillsDirFor } from '../src/config.js';
|
|
12
|
+
import { AGENTS, AGENT_BLOCK, CONFIG_NAME, STAMP_NAME, agentBlockDrift, checkProject, configForPath, docsetsOf, findConfig, profileDrift, profileFor, proposeCorpus, readConfig, readingOrder, skillDrift, skillsDirFor } from '../src/config.js';
|
|
13
13
|
|
|
14
14
|
const USAGE = `cnlp — the CNL-P checker
|
|
15
15
|
|
|
16
16
|
cnlp init [<dir>] [--agents <a,b>] install or update this kit in a project
|
|
17
17
|
cnlp check [<dir>] [--config <path>] check a directory of documents, or every corpus entry
|
|
18
|
+
[--name <set>] [--companion <set>=<dir>]...
|
|
19
|
+
name the set <dir> holds, and the sets it crosses into
|
|
18
20
|
cnlp docs [<set>] write the index of every document set, or of one
|
|
19
21
|
cnlp print [<doc>] [--path] write a shipped document to stdout, or its path
|
|
20
22
|
cnlp scaffold <profile> <data> write a document from a JSON file, in the profile's shape
|
|
21
23
|
cnlp diff <profile> <doc> <data> report what moved between a document and a fresh read
|
|
24
|
+
cnlp --version write the version of this package
|
|
22
25
|
|
|
23
26
|
<doc> is "format" for the standard, or a profile name such as "skill".
|
|
24
27
|
<a,b> is a comma list of ${AGENTS.map((a) => a.value).join(', ')}.
|
|
@@ -36,12 +39,18 @@ const STARTER = {
|
|
|
36
39
|
const VERSION = JSON.parse(await readFile(fileURLToPath(new URL('package.json', PACKAGE_ROOT)), 'utf8')).version;
|
|
37
40
|
|
|
38
41
|
const argv = process.argv.slice(2);
|
|
39
|
-
const VALUE_FLAGS = ['--config', '--agents', '--out'];
|
|
42
|
+
const VALUE_FLAGS = ['--config', '--agents', '--out', '--name', '--companion'];
|
|
40
43
|
const flag = (name) => argv.includes(name);
|
|
41
44
|
const value = (name) => {
|
|
42
45
|
const at = argv.indexOf(name);
|
|
43
|
-
return at >= 0 ?
|
|
46
|
+
return at >= 0 ? valueAt(at + 1) : undefined;
|
|
44
47
|
};
|
|
48
|
+
// A flag right after a flag is no value: `--name --companion x=y` would name the set `--companion`.
|
|
49
|
+
function valueAt(i) {
|
|
50
|
+
return argv[i]?.startsWith('--') ? undefined : argv[i];
|
|
51
|
+
}
|
|
52
|
+
// A flag that repeats: `value` returns the first occurrence only.
|
|
53
|
+
const values = (name) => argv.flatMap((a, i) => (a === name ? [valueAt(i + 1)] : []));
|
|
45
54
|
const positional = argv.filter((a, i) => !a.startsWith('--') && !VALUE_FLAGS.includes(argv[i - 1]));
|
|
46
55
|
|
|
47
56
|
const fail = (message) => {
|
|
@@ -94,6 +103,12 @@ function multiSelect(message, choices, checked) {
|
|
|
94
103
|
|
|
95
104
|
const [command] = positional;
|
|
96
105
|
|
|
106
|
+
// The version alone on stdout, so a script compares it without parsing a sentence.
|
|
107
|
+
if (flag('--version')) {
|
|
108
|
+
console.log(VERSION);
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
111
|
+
|
|
97
112
|
if (!command || flag('--help') || flag('-h')) {
|
|
98
113
|
console.log(USAGE);
|
|
99
114
|
// An asked-for usage is a success; a missing command is not.
|
|
@@ -108,16 +123,39 @@ if (command === 'check') {
|
|
|
108
123
|
if (!target && !configPath) {
|
|
109
124
|
fail(`no ${CONFIG_NAME} found here or above ${process.cwd()} — name a directory to check, or run \`cnlp init\` to write a config`);
|
|
110
125
|
}
|
|
126
|
+
const name = value('--name');
|
|
127
|
+
const companionUsage = 'usage: cnlp check <dir> [--name <set>] [--companion <set>=<dir>]...';
|
|
128
|
+
// `--companion=a=b` read as no flag at all would print no `resolving` line, which a caller reads
|
|
129
|
+
// as an executable older than 3.8.0: refuse the form instead of ignoring it.
|
|
130
|
+
if ((flag('--name') && !name) || argv.some((a) => /^--(name|companion)=/.test(a))) fail(companionUsage);
|
|
131
|
+
// The first `=` splits the value, so a dir may carry `=` and a Windows drive letter.
|
|
132
|
+
const companions = values('--companion').map((v) => {
|
|
133
|
+
const cut = v?.indexOf('=') ?? -1;
|
|
134
|
+
if (cut <= 0 || cut === v.length - 1) fail(`--companion ${v ?? ''}: expected <set>=<dir>
|
|
135
|
+
${companionUsage}`);
|
|
136
|
+
return { name: v.slice(0, cut), dir: v.slice(cut + 1) };
|
|
137
|
+
});
|
|
138
|
+
if (!target && (flag('--name') || companions.length)) {
|
|
139
|
+
fail('--name and --companion apply to a <dir>: a config run already reads every set under its root');
|
|
140
|
+
}
|
|
111
141
|
let result;
|
|
112
142
|
let config;
|
|
113
143
|
try {
|
|
114
|
-
config = target ? await configForPath(target) : await readConfig(configPath);
|
|
115
|
-
result = await checkProject(config);
|
|
144
|
+
config = target ? await configForPath(target, { name, companions }) : await readConfig(configPath);
|
|
116
145
|
} catch (e) {
|
|
117
146
|
fail(e.message);
|
|
118
147
|
}
|
|
119
|
-
// Paths below are relative to a root the caller never named, so name it once.
|
|
148
|
+
// Paths below are relative to a root the caller never named, so name it once. Printed before the
|
|
149
|
+
// check runs, so a caller seeing no `resolving` line concludes an old executable, not a crash.
|
|
120
150
|
if (target) console.log(`reading ${config.corpus.length} entr(ies) under ${config.root}`);
|
|
151
|
+
// The wording is a contract: a caller passing companions detects an executable older than 3.8.0
|
|
152
|
+
// by this line's absence. Keep it stable.
|
|
153
|
+
for (const c of config.companions ?? []) console.log(`resolving against ${c.name} at ${c.dir}`);
|
|
154
|
+
try {
|
|
155
|
+
result = await checkProject(config);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
fail(e.message);
|
|
158
|
+
}
|
|
121
159
|
for (const { file, line, message } of result.issues) {
|
|
122
160
|
console.error(`${file}:${line}: ${message}`);
|
|
123
161
|
}
|
|
@@ -128,10 +166,35 @@ if (command === 'check') {
|
|
|
128
166
|
const from = held ? `are from ${held}` : 'carry no version';
|
|
129
167
|
console.error(`${path.relative(config.root, dir) || dir}: skills ${from}, this package is ${VERSION} — run \`cnlp init\``);
|
|
130
168
|
}
|
|
169
|
+
// A copied profile and a pasted agent block are the 2 artifacts an upgrade cannot reach: the
|
|
170
|
+
// project owns both, so neither is overwritten and both are reported. Like a stale skill, a copy
|
|
171
|
+
// left behind is not a defect in a document the corpus names, and it decides no exit code.
|
|
172
|
+
for (const { file, profile, missing } of await profileDrift(config)) {
|
|
173
|
+
console.error(`${file}: the packaged ${profile} profile declares ${missing.join(', ')}, which this copy does not — compare them with \`npx cnlp print ${profile} --path\``);
|
|
174
|
+
}
|
|
175
|
+
for (const name of await agentBlockDrift(config.root)) {
|
|
176
|
+
console.error(`${name}: carries an earlier copy of the block \`cnlp init\` prints — paste the block that run ends with`);
|
|
177
|
+
}
|
|
178
|
+
// Every word list in §5 is English, so a body in another script matches none of them. Reporting
|
|
179
|
+
// which files the lexicon never reached is the difference between a partial check and a check
|
|
180
|
+
// that claims to be whole.
|
|
181
|
+
if (result.foreign.length) {
|
|
182
|
+
console.log(`${result.foreign.length} file(s) carry no English prose — the §5 word rules (deny list, thresholds, limits, comparatives, nominalizations, passive) did not apply to them`);
|
|
183
|
+
for (const rel of result.foreign) console.log(` ${rel}`);
|
|
184
|
+
}
|
|
185
|
+
if (result.regions.length) {
|
|
186
|
+
console.log(`${result.regions.length} marked region(s) the file's profile did not shape — the §4 and §5 line rules read them`);
|
|
187
|
+
for (const r of result.regions) console.log(` ${r.file}:${r.line}-${r.end}: ${r.name}`);
|
|
188
|
+
}
|
|
131
189
|
if (result.issues.length) {
|
|
132
190
|
fail(`\n${result.issues.length} issue(s) in ${result.checked.length} file(s)`);
|
|
133
191
|
}
|
|
134
192
|
console.log(`${result.checked.length} file(s) conform`);
|
|
193
|
+
// A reader loads the index to answer any question and a page to answer 1, so an author sees
|
|
194
|
+
// which of the 2 they are growing. `profiles/docset.md` states why they are different costs.
|
|
195
|
+
for (const set of result.sets) {
|
|
196
|
+
console.log(`${set.name}: index ${set.indexBytes} B fixed, ${set.pages} page(s) ${set.pageBytes} B on demand`);
|
|
197
|
+
}
|
|
135
198
|
process.exit(0);
|
|
136
199
|
}
|
|
137
200
|
|
|
@@ -324,10 +387,8 @@ if (command === 'init') {
|
|
|
324
387
|
// decision about how that project's agent works. The operator pastes it where their agent reads.
|
|
325
388
|
// 3 lines and not 5: an installed skill announces its own task through its description, so the
|
|
326
389
|
// block states only what a skill cannot — a standing rule answers to no task description.
|
|
327
|
-
console.log(`\nPaste this into AGENTS.md, CLAUDE.md, or whatever file your agent reads:\n
|
|
328
|
-
|
|
329
|
-
- run \`npx cnlp check\` before a commit that touches a document the corpus names
|
|
330
|
-
- load a library's documentation with \`npx cnlp docs\`, which writes every set's index in reading order`);
|
|
390
|
+
console.log(`\nPaste this into AGENTS.md, CLAUDE.md, or whatever file your agent reads:\n`);
|
|
391
|
+
console.log(AGENT_BLOCK.join('\n'));
|
|
331
392
|
process.exit(0);
|
|
332
393
|
}
|
|
333
394
|
|
|
@@ -124,6 +124,22 @@ never carries sub-bullets to hold a second thought — that is two bullets.
|
|
|
124
124
|
document, not CNL-P sections. Anything reading this format skips fenced regions before
|
|
125
125
|
looking for section keys.
|
|
126
126
|
|
|
127
|
+
**An HTML comment is markup, not structure.** A line that is 1 whole `<!-- … -->` comment, and
|
|
128
|
+
every line of a comment opening a line and running over several, is not an item of the block above
|
|
129
|
+
it and not a section key. A line carrying text beside a comment carries that text, and is read. A
|
|
130
|
+
comment that never closes hides nothing, because it would otherwise hide the rest of the file. §4
|
|
131
|
+
and §5 still read comment text: the rendered file hides it, and an agent reads the raw file.
|
|
132
|
+
|
|
133
|
+
**A marked region belongs to the tool that wrote it.** A file several tools write into gives each
|
|
134
|
+
tool its own lines between `<!-- <name>:start -->` and `<!-- <name>:end -->`, so the tool finds and
|
|
135
|
+
rewrites its own block and leaves the rest alone. `<name>` is ASCII letters, digits, `_`, `.` and
|
|
136
|
+
`-`. The file's profile does not shape a region: its keys and headings are not the file's blocks, so
|
|
137
|
+
none of them is unknown, repeated or out of order, and nothing in it supplies a block the profile
|
|
138
|
+
requires. §4 and §5 still hold on every line of it — a region is exempt from the file's shape, not
|
|
139
|
+
from its language. An end closes the latest start of its own name. A start with no end marks
|
|
140
|
+
nothing, because it would otherwise exempt the rest of the file. The checker lists every region it
|
|
141
|
+
did not shape, because a region is an exemption any author can write.
|
|
142
|
+
|
|
127
143
|
**An empty block.** A required block with nothing in it carries `- none` and the reason:
|
|
128
144
|
`- none: no alternative was viable at this scale`. That single line is the whole content and
|
|
129
145
|
replaces the form the block would otherwise take, so a record-list or a keyed-block says it
|
|
@@ -166,6 +182,14 @@ A block whose content is a fenced region is not empty — the fence is opaque, n
|
|
|
166
182
|
- A short concrete verb over an abstract one.
|
|
167
183
|
- Define a specialized term once, then reuse it verbatim.
|
|
168
184
|
|
|
185
|
+
**The word rules are English, and a check says when they did not run.** The 7 bullets above hold
|
|
186
|
+
in any language. Every table and every list below is a list of English words — the deny-list, the
|
|
187
|
+
spelled thresholds, the open limits, the comparatives, the light verbs, the passive — so a body
|
|
188
|
+
written in another script matches none of them and is held only to the rules that read shape:
|
|
189
|
+
§3 block forms, §4 line rules, and the quoted-text rule below. The checker reports every such file
|
|
190
|
+
by name, because a corpus reported as conformant against rules that never ran is worse than a
|
|
191
|
+
corpus nobody checked.
|
|
192
|
+
|
|
169
193
|
**`check the corpus` and `run the check` are 2 terms.** One term per concept fixes which word names a
|
|
170
194
|
thing; one part of speech fixes which job that word does — here the action, and the program that
|
|
171
195
|
performs the action. Keep the part of speech the term already carries and rename the other use:
|
|
@@ -443,9 +467,18 @@ The rules a checker holds:
|
|
|
443
467
|
- every document except a set's `index` is the target of at least 1 reference. A page nothing
|
|
444
468
|
reaches is a page nobody reads, and it goes stale unobserved.
|
|
445
469
|
|
|
446
|
-
**The checker reads this only
|
|
447
|
-
|
|
448
|
-
|
|
470
|
+
**The checker reads this only where it knows a set lives**: a project names the directory whose
|
|
471
|
+
immediate subdirectories are one set each with a `docsets` key in `cnlp.config.json`, and a
|
|
472
|
+
directory checked by path is a set, or the root of several, by the `index.md` it holds. A project
|
|
473
|
+
with no such key and no such directory has no document set, and none of this applies to it.
|
|
474
|
+
|
|
475
|
+
**A set checked on its own resolves its crossings against its companions**: the sets the caller
|
|
476
|
+
names, and the sets beside it. A reference into `toolkit` from a set that extends it resolves
|
|
477
|
+
when `toolkit` is a companion. A companion is read to resolve a reference and is never checked: a
|
|
478
|
+
reference inside it is not reported, and a page of it nothing reaches is not reported, but a
|
|
479
|
+
reference from it into the checked set counts as reaching that page. The sets beside a set join
|
|
480
|
+
only when its name is the name of its directory; a set named apart from its directory sits in a
|
|
481
|
+
layout whose neighbours are not sets, and the caller names every companion.
|
|
449
482
|
|
|
450
483
|
The rules a review holds. They are the same kind of rule as the four above and no checker reaches
|
|
451
484
|
them, the way §5 splits the enforced half of a limit from the reviewed half:
|
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: docset
|
|
3
|
-
description: The CNL-P profile of a docset index — the entry page of a library's agent documentation, and the closed list of what that library offers.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
format: cnlp-format.md
|
|
7
|
-
|
|
8
|
-
mood: imperative
|
|
9
|
-
|
|
10
|
-
headings:
|
|
11
|
-
- none: an index is one flat run of blocks, and the file name carries the title
|
|
12
|
-
|
|
13
|
-
frontmatter_fields:
|
|
14
|
-
- none: an index is read by path, so it carries no routing contract to restate
|
|
15
|
-
|
|
16
|
-
lexicon_exempt:
|
|
17
|
-
- none: an index has no frontmatter, so nothing is exempt
|
|
18
|
-
|
|
19
|
-
sections:
|
|
20
|
-
- key: identity
|
|
21
|
-
form: keyed-block
|
|
22
|
-
required: yes
|
|
23
|
-
record_keys: library, version, source
|
|
24
|
-
optional_record_keys: derived_from
|
|
25
|
-
note: the library covered and the revision it was read at;
|
|
26
|
-
|
|
27
|
-
- key: purpose
|
|
28
|
-
form: bullet-list
|
|
29
|
-
required: yes
|
|
30
|
-
note: what the library does, and the kind of task a reader reaches for it with
|
|
31
|
-
|
|
32
|
-
- key: read_protocol
|
|
33
|
-
form: numbered-list
|
|
34
|
-
required: yes
|
|
35
|
-
note:
|
|
36
|
-
|
|
37
|
-
- key: install
|
|
38
|
-
form: bullet-list
|
|
39
|
-
required: no
|
|
40
|
-
note: how a project takes the dependency; delete the block when the docset covers a dependency the project already carries
|
|
41
|
-
|
|
42
|
-
- key: canonical_use
|
|
43
|
-
form: keyed-block
|
|
44
|
-
required: yes
|
|
45
|
-
record_keys: shows
|
|
46
|
-
note: 1 complete working use of the library, carrying every load-bearing element; a reader holding only the index writes the common case from this block, and an index without one is a table of contents, which is enough for nothing
|
|
47
|
-
|
|
48
|
-
- key: walkthrough
|
|
49
|
-
form: record-list
|
|
50
|
-
required: yes
|
|
51
|
-
record_keys: part, page
|
|
52
|
-
note: each part of `canonical_use:` and the page owning its detail; a record and not a comment inside the fence, because a fence is opaque and a link inside one reaches no reader and no check
|
|
53
|
-
|
|
54
|
-
- key: concepts
|
|
55
|
-
form: record-list
|
|
56
|
-
required: yes
|
|
57
|
-
record_keys: page, does
|
|
58
|
-
note: the models a reader holds before any page reads correctly; a record and not a line, so every link in a docset is a `page:` value and a resolver guesses at none of them
|
|
59
|
-
|
|
60
|
-
- key: tasks
|
|
61
|
-
form: record-list
|
|
62
|
-
required: yes
|
|
63
|
-
record_keys: page, does
|
|
64
|
-
note: the goals a reader arrives with; a catalog keyed on capability routes a reader holding a symbol and leaves one holding a goal to guess
|
|
65
|
-
|
|
66
|
-
- key: catalog
|
|
67
|
-
form: record-list
|
|
68
|
-
required: yes
|
|
69
|
-
record_keys: capability, does, status
|
|
70
|
-
optional_record_keys: page, read, instead, fingerprint
|
|
71
|
-
record_values: status = covered, source, avoid
|
|
72
|
-
note: "every capability the library offers, in 1 record each; status is covered, source or avoid, and a symbol absent here is a symbol the library lacks; fingerprint changes when the source does, so a record without one cannot report drift"
|
|
73
|
-
|
|
74
|
-
- key: excluded
|
|
75
|
-
form: bullet-list
|
|
76
|
-
required: yes
|
|
77
|
-
note: a whole region the docset leaves out, with the reason; a region named in neither this block nor the catalog reads as an omission nobody decided
|
|
78
|
-
|
|
79
|
-
- key: companions
|
|
80
|
-
form: record-list
|
|
81
|
-
required: yes
|
|
82
|
-
record_keys: docset, when, owns
|
|
83
|
-
note: another docset covering what this library does not; `when:` is "always" once its index carries `overrides:`, since a reader loading it on demand misses the correction; "- none" when the library stands alone
|
|
84
|
-
|
|
85
|
-
- key: overrides
|
|
86
|
-
form: record-list
|
|
87
|
-
required: no
|
|
88
|
-
record_keys: page, by, reason
|
|
89
|
-
note: "a page of another docset that stops holding once this package is present, and the page here that replaces it; `page:` names the foreign page and carries its docset prefix, `by:` names the local one and carries none"
|
|
90
|
-
|
|
91
|
-
- key: version_notes
|
|
92
|
-
form: bullet-list
|
|
93
|
-
required: no
|
|
94
|
-
note: what changed in the covered version, for a reader arriving from an older one
|
|
95
|
-
|
|
96
|
-
custom_sections:
|
|
97
|
-
- none: a project fills this from the vocabulary its own docsets already use
|
|
98
|
-
|
|
99
|
-
enforcement:
|
|
100
|
-
- `npx cnlp check`, over the files a `cnlp.config.json` corpus entry maps to this profile
|
|
101
|
-
- any issue is an error: an index routes every other page, so a wrong index misroutes the whole docset
|
|
102
|
-
- `npx cnlp check` resolves every `page:`, `by:` and `docset:` value once the project declares `docsets:`
|
|
103
|
-
- `npx cnlp check` holds `status` to covered, source and avoid; what a status implies — a `read:` on source, an `instead:` on avoid — is caught by review
|
|
1
|
+
---
|
|
2
|
+
name: docset
|
|
3
|
+
description: The CNL-P profile of a docset index — the entry page of a library's agent documentation, and the closed list of what that library offers.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
format: cnlp-format.md
|
|
7
|
+
|
|
8
|
+
mood: imperative
|
|
9
|
+
|
|
10
|
+
headings:
|
|
11
|
+
- none: an index is one flat run of blocks, and the file name carries the title
|
|
12
|
+
|
|
13
|
+
frontmatter_fields:
|
|
14
|
+
- none: an index is read by path, so it carries no routing contract to restate
|
|
15
|
+
|
|
16
|
+
lexicon_exempt:
|
|
17
|
+
- none: an index has no frontmatter, so nothing is exempt
|
|
18
|
+
|
|
19
|
+
sections:
|
|
20
|
+
- key: identity
|
|
21
|
+
form: keyed-block
|
|
22
|
+
required: yes
|
|
23
|
+
record_keys: library, version, source
|
|
24
|
+
optional_record_keys: derived_from
|
|
25
|
+
note: the library covered and the revision it was read at; `version:` opens with the version alone, as a tool reads it up to the first comma; derived_from names the sources, because prose carries less weight than a signature
|
|
26
|
+
|
|
27
|
+
- key: purpose
|
|
28
|
+
form: bullet-list
|
|
29
|
+
required: yes
|
|
30
|
+
note: what the library does, and the kind of task a reader reaches for it with
|
|
31
|
+
|
|
32
|
+
- key: read_protocol
|
|
33
|
+
form: numbered-list
|
|
34
|
+
required: yes
|
|
35
|
+
note: the index is the cost of every question and a page the cost of 1: the common case is written from `canonical_use:`, a page opens for a contract the reader lacks, and a step that stops the reader is the defect
|
|
36
|
+
|
|
37
|
+
- key: install
|
|
38
|
+
form: bullet-list
|
|
39
|
+
required: no
|
|
40
|
+
note: how a project takes the dependency; delete the block when the docset covers a dependency the project already carries
|
|
41
|
+
|
|
42
|
+
- key: canonical_use
|
|
43
|
+
form: keyed-block
|
|
44
|
+
required: yes
|
|
45
|
+
record_keys: shows
|
|
46
|
+
note: 1 complete working use of the library, carrying every load-bearing element; a reader holding only the index writes the common case from this block, and an index without one is a table of contents, which is enough for nothing
|
|
47
|
+
|
|
48
|
+
- key: walkthrough
|
|
49
|
+
form: record-list
|
|
50
|
+
required: yes
|
|
51
|
+
record_keys: part, page
|
|
52
|
+
note: each part of `canonical_use:` and the page owning its detail; a record and not a comment inside the fence, because a fence is opaque and a link inside one reaches no reader and no check
|
|
53
|
+
|
|
54
|
+
- key: concepts
|
|
55
|
+
form: record-list
|
|
56
|
+
required: yes
|
|
57
|
+
record_keys: page, does
|
|
58
|
+
note: the models a reader holds before any page reads correctly; a record and not a line, so every link in a docset is a `page:` value and a resolver guesses at none of them
|
|
59
|
+
|
|
60
|
+
- key: tasks
|
|
61
|
+
form: record-list
|
|
62
|
+
required: yes
|
|
63
|
+
record_keys: page, does
|
|
64
|
+
note: the goals a reader arrives with; a catalog keyed on capability routes a reader holding a symbol and leaves one holding a goal to guess
|
|
65
|
+
|
|
66
|
+
- key: catalog
|
|
67
|
+
form: record-list
|
|
68
|
+
required: yes
|
|
69
|
+
record_keys: capability, does, status
|
|
70
|
+
optional_record_keys: page, read, instead, fingerprint
|
|
71
|
+
record_values: status = covered, source, avoid
|
|
72
|
+
note: "every capability the library offers, in 1 record each; status is covered, source or avoid, and a symbol absent here is a symbol the library lacks; fingerprint changes when the source does, so a record without one cannot report drift"
|
|
73
|
+
|
|
74
|
+
- key: excluded
|
|
75
|
+
form: bullet-list
|
|
76
|
+
required: yes
|
|
77
|
+
note: a whole region the docset leaves out, with the reason; a region named in neither this block nor the catalog reads as an omission nobody decided
|
|
78
|
+
|
|
79
|
+
- key: companions
|
|
80
|
+
form: record-list
|
|
81
|
+
required: yes
|
|
82
|
+
record_keys: docset, when, owns
|
|
83
|
+
note: another docset covering what this library does not; `when:` is "always" once its index carries `overrides:`, since a reader loading it on demand misses the correction; "- none" when the library stands alone
|
|
84
|
+
|
|
85
|
+
- key: overrides
|
|
86
|
+
form: record-list
|
|
87
|
+
required: no
|
|
88
|
+
record_keys: page, by, reason
|
|
89
|
+
note: "a page of another docset that stops holding once this package is present, and the page here that replaces it; `page:` names the foreign page and carries its docset prefix, `by:` names the local one and carries none"
|
|
90
|
+
|
|
91
|
+
- key: version_notes
|
|
92
|
+
form: bullet-list
|
|
93
|
+
required: no
|
|
94
|
+
note: what changed in the covered version, for a reader arriving from an older one
|
|
95
|
+
|
|
96
|
+
custom_sections:
|
|
97
|
+
- none: a project fills this from the vocabulary its own docsets already use
|
|
98
|
+
|
|
99
|
+
enforcement:
|
|
100
|
+
- `npx cnlp check`, over the files a `cnlp.config.json` corpus entry maps to this profile
|
|
101
|
+
- any issue is an error: an index routes every other page, so a wrong index misroutes the whole docset
|
|
102
|
+
- `npx cnlp check` resolves every `page:`, `by:` and `docset:` value once the project declares `docsets:`
|
|
103
|
+
- `npx cnlp check` holds `status` to covered, source and avoid; what a status implies — a `read:` on source, an `instead:` on avoid — is caught by review
|
|
@@ -51,7 +51,7 @@ workflow:
|
|
|
51
51
|
1. run `npx cnlp print format` and read it in full, §9 included: it states what a page reference is, and which of its rules a review holds rather than the checker
|
|
52
52
|
2. read the `docset`, `concept`, `api` and `task` profiles through `npx cnlp print <name>`: their `note:` keys carry the reason behind each block, and the rules and the values are read together
|
|
53
53
|
3. list every source the operator supplied and name the weight each one carries: the strongest weight in that list bounds what every page below claims, so report it before writing anything
|
|
54
|
-
4. run the clone or the install command each source needs, at a fixed revision, and record the library
|
|
54
|
+
4. run the clone or the install command each source needs, at a fixed revision, and record the library in `identity:`, and in `version:` the version alone, then a comma and the revision
|
|
55
55
|
5. fill `derived_from:` in `identity:` with those sources and their weights, so a reader tells a set written from declarations from a set written from prose
|
|
56
56
|
6. list every capability of the library, taking each one from the strongest source that states it: a source at weight 3 or below covers what its author chose to cover, so a capability it omits is a capability still to find
|
|
57
57
|
7. fill `catalog:` with 1 record per capability, every one at `status: source`, `read:` naming where to read it — the file, or the document and the anchor inside it
|
|
@@ -63,9 +63,11 @@ workflow:
|
|
|
63
63
|
13. write 1 task page per goal a reader arrives with, and put the multi-call composition there
|
|
64
64
|
14. update each catalog record to `covered` as its page lands, and fill `excluded:` with every whole region the set leaves out, each with its reason
|
|
65
65
|
15. fill `companions:` when another package extends this library, and `overrides:` when this package corrects a page of the set it extends
|
|
66
|
-
16.
|
|
67
|
-
17.
|
|
68
|
-
18.
|
|
66
|
+
16. name every set `companions:` or `overrides:` names that does not sit beside `<output_directory>`, and the directory holding it: the check resolves a crossing only against a set it can read
|
|
67
|
+
17. run `npx cnlp check <output_directory>`, with `--companion <set>=<dir>` for each set step 16 named, and fix what it reports: it resolves every reference in the set, and reports a page no other page reaches
|
|
68
|
+
18. read the finished set for the rules no check reaches: 1 fact stated on 1 page, 1 running example across the set, 1 page per routed name, and the keys a status implies
|
|
69
|
+
19. read each task page against the API pages it sends a reader to: a member the task steers away from carries that in its own `pitfalls:`, because a full API page otherwise outvotes a task page's preference
|
|
70
|
+
20. report the capabilities covered, the ones left at `source`, the weight the set rests on, and any defect the reading turned up in the library itself
|
|
69
71
|
|
|
70
72
|
invocation:
|
|
71
73
|
- Claude Code: `/cnlp-docset <sources> <output-directory>`, once this folder sits in `.claude/skills/`
|
|
@@ -29,21 +29,57 @@ export const HARD_LIMIT = 250; // §4: 150 is the target, 250 is compound whatev
|
|
|
29
29
|
|
|
30
30
|
const FORMS = new Set(['scalar', 'bullet-list', 'numbered-list', 'record-list', 'keyed-block']);
|
|
31
31
|
const KEY = /^([a-z][a-z0-9_]*):(.*)$/;
|
|
32
|
+
/** §3: a line that is 1 whole comment. `<!-- a --> - item` carries an item, and is read. */
|
|
33
|
+
const COMMENT = /^\s*<!--(?:(?!-->).)*-->\s*$/;
|
|
34
|
+
/** §3: the markers of a region another tool writes into the file. */
|
|
35
|
+
const REGION = /^\s*<!--\s*([\w.-]+):(start|end)\s*-->\s*$/;
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
* Body split into lines, top-level `key:` sections and `##` headings
|
|
35
|
-
*
|
|
36
|
-
*
|
|
38
|
+
* Body split into lines, top-level `key:` sections and `##` headings. §3: a fence is blanked in
|
|
39
|
+
* both `lines` and `prose`, because its contents are data. An HTML comment and a marked region are
|
|
40
|
+
* blanked in `lines` only, which is the shape the profile reads: both stay in `prose`, which the
|
|
41
|
+
* lexicon reads, because an agent reads the raw file. Leading frontmatter is blanked when
|
|
42
|
+
* present. Line numbers are 1-based within the file.
|
|
37
43
|
*/
|
|
38
44
|
export function parseBlocks(raw) {
|
|
39
|
-
|
|
45
|
+
// Blanked, not removed: a reported line is a line of the file, frontmatter included.
|
|
46
|
+
const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, (m) => '\n'.repeat(m.split('\n').length - 1));
|
|
47
|
+
const source = body.split(/\r?\n/);
|
|
48
|
+
// A comment that never closes hides nothing: it would otherwise exempt the rest of the file.
|
|
49
|
+
const lastClose = source.findLastIndex((l) => l.includes('-->'));
|
|
40
50
|
let fenced = false;
|
|
51
|
+
let comment = false;
|
|
41
52
|
const fences = []; // 1-based line numbers a fence covered: blanked, but not absent
|
|
42
|
-
const
|
|
53
|
+
const marks = [];
|
|
54
|
+
const hidden = new Map(); // 0-based line -> what is left of it once its comment is taken off
|
|
55
|
+
const prose = source.map((l, i) => {
|
|
56
|
+
if (comment) {
|
|
57
|
+
const end = l.indexOf('-->');
|
|
58
|
+
if (end >= 0) comment = false;
|
|
59
|
+
hidden.set(i, end < 0 ? '' : l.slice(end + 3));
|
|
60
|
+
return l;
|
|
61
|
+
}
|
|
43
62
|
if (/^\s*```/.test(l)) { fenced = !fenced; fences.push(i + 1); return ''; }
|
|
44
63
|
if (fenced) { fences.push(i + 1); return ''; }
|
|
64
|
+
const mark = l.match(REGION);
|
|
65
|
+
if (mark) marks.push({ name: mark[1], end: mark[2] === 'end', at: i });
|
|
66
|
+
if (COMMENT.test(l)) hidden.set(i, '');
|
|
67
|
+
else if (/^\s*<!--/.test(l) && !l.includes('-->') && i < lastClose) { comment = true; hidden.set(i, ''); }
|
|
45
68
|
return l;
|
|
46
69
|
});
|
|
70
|
+
// An end closes the latest start of its own name, so regions of 2 names never cancel each other.
|
|
71
|
+
// A start with no end marks nothing, and an end with no start is a comment like any other.
|
|
72
|
+
const regions = [];
|
|
73
|
+
const open = new Map();
|
|
74
|
+
for (const m of marks) {
|
|
75
|
+
if (!m.end) open.set(m.name, m.at);
|
|
76
|
+
else if (open.has(m.name)) {
|
|
77
|
+
regions.push({ name: m.name, line: open.get(m.name) + 1, end: m.at + 1 });
|
|
78
|
+
open.delete(m.name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const inRegion = (i) => regions.some((r) => i + 1 > r.line && i + 1 < r.end);
|
|
82
|
+
const lines = prose.map((l, i) => (inRegion(i) ? '' : hidden.get(i) ?? l));
|
|
47
83
|
const sections = [];
|
|
48
84
|
const headings = [];
|
|
49
85
|
lines.forEach((l, i) => {
|
|
@@ -51,7 +87,8 @@ export function parseBlocks(raw) {
|
|
|
51
87
|
if (key) sections.push({ key: key[1], value: key[2].trim(), line: i + 1 });
|
|
52
88
|
if (/^##\s/.test(l)) headings.push({ text: l.trim(), line: i + 1 });
|
|
53
89
|
});
|
|
54
|
-
|
|
90
|
+
// A fence inside a region is the region's content, not content of the block above the region.
|
|
91
|
+
return { lines, prose, sections, headings, fences: new Set(fences.filter((n) => !inRegion(n - 1))), regions };
|
|
55
92
|
}
|
|
56
93
|
|
|
57
94
|
/** Lines of a section: everything up to the next top-level key or `##` heading. */
|
|
@@ -202,7 +239,7 @@ export async function loadProfile(name, opts) {
|
|
|
202
239
|
* about the document as a whole.
|
|
203
240
|
*/
|
|
204
241
|
export function bodyIssues(raw, profile) {
|
|
205
|
-
const { lines, sections, headings, fences } = parseBlocks(raw);
|
|
242
|
+
const { lines, prose, sections, headings, fences } = parseBlocks(raw);
|
|
206
243
|
const issues = [];
|
|
207
244
|
const at = (line, message) => issues.push({ line, message });
|
|
208
245
|
const byKey = new Map(profile.sections.map((s) => [s.key, s]));
|
|
@@ -271,7 +308,8 @@ export function bodyIssues(raw, profile) {
|
|
|
271
308
|
issues.push(...formIssues(key, spec, items));
|
|
272
309
|
}
|
|
273
310
|
|
|
274
|
-
|
|
311
|
+
// A region is exempt from the file's shape, not from the language: the lexicon reads it.
|
|
312
|
+
issues.push(...lexiconIssues(prose, profile.mood));
|
|
275
313
|
return issues.sort((a, b) => a.line - b.line);
|
|
276
314
|
}
|
|
277
315
|
|
|
@@ -502,7 +540,9 @@ export function lexiconIssues(lines, mood = '') {
|
|
|
502
540
|
// A converted document is where these 2 turn up: a mechanical split reads an ordinal as a
|
|
503
541
|
// sentence and leaves `- 4` behind, and cutting a line to the hard limit lands mid-span.
|
|
504
542
|
const item = l.match(/^\s*(?:[-*]|\d+\.)\s+(.*)$/);
|
|
505
|
-
|
|
543
|
+
// Any script's letter, not an ASCII one: the rule rejects an item made of digits and
|
|
544
|
+
// punctuation, and a body written in Cyrillic or CJK states its claims in letters too.
|
|
545
|
+
if (item && !/\p{L}/u.test(item[1])) issues.push({ line, message: 'the item carries no word — it states no claim' });
|
|
506
546
|
for (const [name, open, close] of UNCLOSED) {
|
|
507
547
|
const n = (l.match(open) || []).length;
|
|
508
548
|
if (close ? n !== (l.match(close) || []).length : n % 2) {
|
|
@@ -531,4 +571,26 @@ export function lexiconIssues(lines, mood = '') {
|
|
|
531
571
|
return issues;
|
|
532
572
|
}
|
|
533
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Whether the §5 lexicon reaches a document. Every word list in §5 — the deny list, the spelled
|
|
576
|
+
* thresholds, the open limits, the comparatives, the light verbs, the passive — is English, so a
|
|
577
|
+
* body written in another script matches none of them and would report clean against rules that
|
|
578
|
+
* never ran. The prose decides it, which is the line with its section key, its heading marker and
|
|
579
|
+
* its quoted spans taken off: a key is this format's own vocabulary and English in every document,
|
|
580
|
+
* and a Russian line naming `CombatFeature.cs` is still a Russian line.
|
|
581
|
+
* ponytail: this reads script, not language — a German or a Spanish body reads as English here, and
|
|
582
|
+
* only its reader can say otherwise.
|
|
583
|
+
*/
|
|
584
|
+
export function lexiconApplies(raw) {
|
|
585
|
+
const prose = raw
|
|
586
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
587
|
+
.split(/\r?\n/)
|
|
588
|
+
.filter((l) => !/^\s*#/.test(l))
|
|
589
|
+
.map((l) => unquoted(l).replace(/^\s*(?:[-*]\s+|\d+\.\s+)?[a-z][a-z0-9_]*:/, ' '))
|
|
590
|
+
.join('\n');
|
|
591
|
+
const letters = prose.match(/\p{L}/gu) ?? [];
|
|
592
|
+
const latin = letters.filter((c) => /[a-z]/i.test(c)).length;
|
|
593
|
+
return latin * 2 >= letters.length;
|
|
594
|
+
}
|
|
595
|
+
|
|
534
596
|
export { FORMS };
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// inside a file the next upgrade overwrote.
|
|
5
5
|
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
|
-
import { glob, readdir, readFile } from 'node:fs/promises';
|
|
7
|
+
import { glob, readdir, readFile, realpath } from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import { bodyIssues, loadProfileAt, pageRefs, resolveDoc } from './cnlp.js';
|
|
9
|
+
import { bodyIssues, lexiconApplies, loadProfileAt, pageRefs, parseBlocks, resolveDoc } from './cnlp.js';
|
|
10
10
|
|
|
11
11
|
export const CONFIG_NAME = 'cnlp.config.json';
|
|
12
12
|
|
|
@@ -124,24 +124,48 @@ export function readingOrder(sets) {
|
|
|
124
124
|
return out;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/** `pageIdOf`, with the set renamed when the caller named it apart from its directory. */
|
|
128
|
+
const renamedIdOf = (rel, docsets, rename) => {
|
|
129
|
+
const id = pageIdOf(rel, docsets);
|
|
130
|
+
return id && { ...id, docset: rename[id.docset] ?? id.docset };
|
|
131
|
+
};
|
|
132
|
+
|
|
127
133
|
/**
|
|
128
134
|
* §9 reference conformance across a corpus, which is the 1 check no single document can make.
|
|
129
135
|
* `docs` is every file already read, so this adds no read of its own.
|
|
136
|
+
*
|
|
137
|
+
* `context` holds the pages of companion sets as `{ set, page, raw }`: they resolve references and
|
|
138
|
+
* are never checked. Their id comes with them rather than from a path, because a companion may sit
|
|
139
|
+
* outside the root, in a folder named by a version rather than by its set.
|
|
130
140
|
*/
|
|
131
|
-
export function linkIssues(docs, docsets) {
|
|
141
|
+
export function linkIssues(docs, docsets, { context = [], rename = {} } = {}) {
|
|
132
142
|
const pages = new Map();
|
|
133
143
|
const sets = new Set();
|
|
134
144
|
for (const { rel } of docs) {
|
|
135
|
-
const id =
|
|
145
|
+
const id = renamedIdOf(rel, docsets, rename);
|
|
136
146
|
if (!id) continue;
|
|
137
147
|
pages.set(`${id.docset}:${id.page}`, rel);
|
|
138
148
|
sets.add(id.docset);
|
|
139
149
|
}
|
|
150
|
+
const known = new Set(pages.keys());
|
|
151
|
+
for (const { set, page } of context) {
|
|
152
|
+
known.add(`${set}:${page}`);
|
|
153
|
+
sets.add(set);
|
|
154
|
+
}
|
|
140
155
|
|
|
141
156
|
const issues = [];
|
|
142
157
|
const reached = new Set();
|
|
158
|
+
// A companion's references are not this run's to report, but they count toward reach: a page of
|
|
159
|
+
// an integration set is reached only from the companion that overrides it.
|
|
160
|
+
for (const { set, page, raw } of context) {
|
|
161
|
+
for (const ref of pageRefs(raw)) {
|
|
162
|
+
if (ref.key === 'docset') continue;
|
|
163
|
+
const qualified = ref.target.includes(':') ? ref.target : `${set}:${ref.target}`;
|
|
164
|
+
if (qualified !== `${set}:${page}`) reached.add(qualified);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
143
167
|
for (const { rel, raw } of docs) {
|
|
144
|
-
const from =
|
|
168
|
+
const from = renamedIdOf(rel, docsets, rename);
|
|
145
169
|
if (!from) continue;
|
|
146
170
|
const self = `${from.docset}:${from.page}`;
|
|
147
171
|
for (const ref of pageRefs(raw)) {
|
|
@@ -157,7 +181,7 @@ export function linkIssues(docs, docsets) {
|
|
|
157
181
|
if (ref.key === 'by' && ref.target.includes(':')) {
|
|
158
182
|
issues.push({ file: rel, line: ref.line, message: `"by: ${ref.target}" carries a document set prefix — a "by:" value names a page of this set` });
|
|
159
183
|
}
|
|
160
|
-
if (!
|
|
184
|
+
if (!known.has(qualified)) {
|
|
161
185
|
issues.push({ file: rel, line: ref.line, message: `page "${qualified}" does not resolve to a file under ${docsets}/` });
|
|
162
186
|
} else if (qualified !== self) {
|
|
163
187
|
reached.add(qualified);
|
|
@@ -217,6 +241,85 @@ export async function skillDrift(root, agents, version) {
|
|
|
217
241
|
return out;
|
|
218
242
|
}
|
|
219
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The standing rules `cnlp init` hands an operator to paste into the file their agent reads. 1
|
|
246
|
+
* value and not a string inside the command, because `cnlp check` reads it too: a pasted copy is a
|
|
247
|
+
* copy that goes stale, and nothing else in the project records that it was taken.
|
|
248
|
+
*/
|
|
249
|
+
export const AGENT_BLOCK = [
|
|
250
|
+
'- the CNL-P standard is the authority on document form: read it with `npx cnlp print format`',
|
|
251
|
+
'- run `npx cnlp check` before a commit that touches a document the corpus names',
|
|
252
|
+
"- load a library's documentation with `npx cnlp docs`, which writes every set's index in reading order",
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
// The 2 files an agent reads by convention, which are the 2 the corpus proposal below already
|
|
256
|
+
// knows. The block is pasted by hand, so nothing records which file took it.
|
|
257
|
+
const AGENT_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The agent files carrying a copy of `AGENT_BLOCK` from an earlier version. A bullet naming
|
|
261
|
+
* `npx cnlp print format` is the block's own opener and is what finds the copy.
|
|
262
|
+
* ponytail: that phrase is the anchor — a version that stops naming it loses the watch on every
|
|
263
|
+
* copy already pasted, and the answer then is markers written into the file.
|
|
264
|
+
*/
|
|
265
|
+
export async function agentBlockDrift(root) {
|
|
266
|
+
const out = [];
|
|
267
|
+
for (const name of AGENT_FILES) {
|
|
268
|
+
const at = path.join(root, name);
|
|
269
|
+
if (!existsSync(at)) continue;
|
|
270
|
+
const lines = (await readFile(at, 'utf8')).split(/\r?\n/).map((l) => l.trimEnd());
|
|
271
|
+
const start = lines.findIndex((l) => /^-\s.*`npx cnlp print format`/.test(l));
|
|
272
|
+
if (start < 0) continue; // the project never took the block
|
|
273
|
+
if (lines.slice(start, start + AGENT_BLOCK.length).join('\n') !== AGENT_BLOCK.join('\n')) out.push(name);
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The local profiles a packaged profile of the same name has outgrown. A project copies a packaged
|
|
280
|
+
* profile to fill its own `custom_sections`, and `profileFor` reads the copy from then on: an
|
|
281
|
+
* upgrade moves the original underneath it, and the check reports a conformant corpus against a
|
|
282
|
+
* declaration this package has since changed. What a copy adds is the vocabulary it was copied for
|
|
283
|
+
* and is never drift; what the packaged profile declares and the copy does not is.
|
|
284
|
+
*/
|
|
285
|
+
export async function profileDrift(config) {
|
|
286
|
+
const out = [];
|
|
287
|
+
if (!config.profilesDir) return out;
|
|
288
|
+
for (const name of [...new Set(config.corpus.map((e) => e.profile))].sort()) {
|
|
289
|
+
const local = path.join(config.profilesDir, `${name}.md`);
|
|
290
|
+
const packagedAt = resolveDoc(name);
|
|
291
|
+
if (!existsSync(local) || !existsSync(packagedAt) || path.resolve(packagedAt) === path.resolve(local)) continue;
|
|
292
|
+
const packaged = await loadProfileAt(packagedAt);
|
|
293
|
+
const mine = await loadProfileAt(local);
|
|
294
|
+
const byKey = new Map(mine.sections.map((s) => [s.key, s]));
|
|
295
|
+
const missing = [];
|
|
296
|
+
for (const want of packaged.sections) {
|
|
297
|
+
const has = byKey.get(want.key);
|
|
298
|
+
if (!has) {
|
|
299
|
+
missing.push(`section "${want.key}"`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (has.form !== want.form) missing.push(`section "${want.key}" as a ${want.form}`);
|
|
303
|
+
if (want.required && !has.required) missing.push(`section "${want.key}" as required`);
|
|
304
|
+
for (const k of want.recordKeys) {
|
|
305
|
+
if (!has.recordKeys.includes(k)) missing.push(`record key "${want.key}.${k}"`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const h of packaged.headings) {
|
|
309
|
+
if (!mine.headings.includes(h)) missing.push(`heading "${h}"`);
|
|
310
|
+
}
|
|
311
|
+
for (const f of packaged.frontmatterFields) {
|
|
312
|
+
if (!mine.frontmatterFields.includes(f)) missing.push(`frontmatter field "${f}"`);
|
|
313
|
+
}
|
|
314
|
+
if (packaged.mood && packaged.mood !== mine.mood) missing.push(`mood "${packaged.mood}"`);
|
|
315
|
+
// Forward slashes, like every other path this reports: the checker names a file the same way
|
|
316
|
+
// on every platform.
|
|
317
|
+
const rel = path.relative(config.root, local).split(path.sep).join('/');
|
|
318
|
+
if (missing.length) out.push({ profile: name, file: rel, missing });
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
220
323
|
// The layouts a corpus entry is usually written for. `cnlp init` keeps the ones that match a file,
|
|
221
324
|
// so a project adopting the format starts from its own layout rather than from an example of one.
|
|
222
325
|
// ponytail: path convention only, never content — a decision record filed under docs/references
|
|
@@ -272,21 +375,69 @@ export async function proposeCorpus(root) {
|
|
|
272
375
|
* An entry matching no file is dropped here, which a written config is right to refuse: a glob a
|
|
273
376
|
* project typed and no file answers is a mistake, and a glob this function guessed is a folder the
|
|
274
377
|
* set does not have yet.
|
|
378
|
+
*
|
|
379
|
+
* 1 set checked alone still resolves its crossings: `companions` names the sets it crosses into,
|
|
380
|
+
* each `{ name, dir }`, and the sets beside it join as companions named by their directories. A
|
|
381
|
+
* companion is read to resolve a reference and never checked. `name` names the checked set when
|
|
382
|
+
* its directory does not, as in a layout of `<set>/<version>/`.
|
|
275
383
|
*/
|
|
276
|
-
export async function configForPath(dir) {
|
|
277
|
-
|
|
278
|
-
|
|
384
|
+
export async function configForPath(dir, { name, companions: named = [] } = {}) {
|
|
385
|
+
if (!existsSync(path.resolve(dir))) throw new Error(`no such directory: ${dir}`);
|
|
386
|
+
// The spelling on disk, not the one typed: on a case-insensitive disk `DragonECS-Unity` would
|
|
387
|
+
// otherwise miss its own directory among the siblings and read itself as a companion.
|
|
388
|
+
const abs = await realpath(path.resolve(dir));
|
|
389
|
+
if (!Array.isArray(named)) throw new Error(`companions is a list of { name, dir }, found ${JSON.stringify(named)}`);
|
|
279
390
|
let root;
|
|
280
391
|
let docsets = null;
|
|
281
392
|
let corpus;
|
|
282
|
-
|
|
393
|
+
const oneSet = existsSync(path.join(abs, 'index.md'));
|
|
394
|
+
// A root check already reads every set under it, and a project has no single set to name.
|
|
395
|
+
if (!oneSet && (name !== undefined || named.length)) {
|
|
396
|
+
throw new Error(`${abs} is not 1 document set — a name and companions apply to a directory holding an index.md`);
|
|
397
|
+
}
|
|
398
|
+
const base = path.basename(abs);
|
|
399
|
+
const setName = name ?? base;
|
|
400
|
+
const companions = [];
|
|
401
|
+
if (oneSet) {
|
|
402
|
+
if (name !== undefined) checkSetName(name, 'the checked set');
|
|
403
|
+
for (const c of named) {
|
|
404
|
+
checkSetName(c.name, 'a companion');
|
|
405
|
+
if (!c.dir) throw new Error(`companion "${c.name}" names no dir — give the directory of 1 document set`);
|
|
406
|
+
if (!existsSync(path.resolve(c.dir))) throw new Error(`companion "${c.name}": no such directory: ${path.resolve(c.dir)}`);
|
|
407
|
+
const at = await realpath(path.resolve(c.dir));
|
|
408
|
+
// The checked set read again under another name would reach its own pages through the copy.
|
|
409
|
+
if (at === abs) throw new Error(`companion "${c.name}" is the checked set itself — name another set`);
|
|
410
|
+
if (!existsSync(path.join(at, 'index.md'))) {
|
|
411
|
+
throw new Error(`companion "${c.name}": ${at} holds no index.md — name the directory of 1 document set`);
|
|
412
|
+
}
|
|
413
|
+
if (c.name === setName) throw new Error(`companion "${c.name}" carries the name of the checked set — name it apart`);
|
|
414
|
+
if (companions.some((o) => o.name === c.name)) throw new Error(`companion "${c.name}" is named twice — name each set once`);
|
|
415
|
+
companions.push({ name: c.name, dir: at });
|
|
416
|
+
}
|
|
417
|
+
// A set is present because a project vendors it, so the sets beside it are its companions.
|
|
418
|
+
// A name apart from the directory means no flat layout: the siblings there are other versions
|
|
419
|
+
// of the same set, and reading them as sets would resolve nothing.
|
|
420
|
+
// ponytail: a sibling's name is its directory's and is not held to `checkSetName`; a directory
|
|
421
|
+
// carrying ":" is possible only off Windows, and it would print as a set nobody can reference.
|
|
422
|
+
if (setName === base) {
|
|
423
|
+
const parent = path.dirname(abs);
|
|
424
|
+
for (const entry of (await readdir(parent, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
425
|
+
const at = path.join(parent, entry.name);
|
|
426
|
+
if (!entry.isDirectory() || entry.name === base || !existsSync(path.join(at, 'index.md'))) continue;
|
|
427
|
+
// An explicit companion wins, by name or by directory: 1 set is read once.
|
|
428
|
+
if (companions.some((o) => o.name === entry.name || o.dir === at)) continue;
|
|
429
|
+
companions.push({ name: entry.name, dir: at });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (oneSet) {
|
|
283
434
|
root = path.dirname(path.dirname(abs));
|
|
284
435
|
docsets = path.basename(path.dirname(abs));
|
|
285
|
-
corpus = docsetCorpus(`${docsets}/${
|
|
436
|
+
corpus = docsetCorpus(`${globLiteral(docsets)}/${globLiteral(base)}`);
|
|
286
437
|
} else if (await holdsASet(abs)) {
|
|
287
438
|
root = path.dirname(abs);
|
|
288
439
|
docsets = path.basename(abs);
|
|
289
|
-
corpus = docsetCorpus(`${docsets}/*`);
|
|
440
|
+
corpus = docsetCorpus(`${globLiteral(docsets)}/*`);
|
|
290
441
|
} else {
|
|
291
442
|
// Neither shape: fall back to the layouts `cnlp init` already knows, so pointing at a project
|
|
292
443
|
// reads the same as pointing at a set.
|
|
@@ -301,7 +452,29 @@ export async function configForPath(dir) {
|
|
|
301
452
|
if (kept.length === 0) {
|
|
302
453
|
throw new Error(`${abs} holds no document this kit knows the layout of — a document set is an index.md beside concepts, api and tasks`);
|
|
303
454
|
}
|
|
304
|
-
|
|
455
|
+
// `companions` is always present, `[]` included: a caller tells a kit that ignores the option
|
|
456
|
+
// from a set that failed by this field's absence.
|
|
457
|
+
const rename = setName === base ? {} : { [base]: setName };
|
|
458
|
+
return { path: null, root, profilesDir: null, docsets, corpus: kept, agents: [], companions, rename };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** A set name keys every page id, and `:` separates `<set>:<name>`, so a name carrying it reads as a page. */
|
|
462
|
+
function checkSetName(name, whose) {
|
|
463
|
+
if (typeof name !== 'string' || name === '' || /[:/\\]/.test(name)) {
|
|
464
|
+
throw new Error(`${JSON.stringify(name)} is no name for ${whose} — a set name is not empty and carries no ":", "/" or "\\"`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* A directory name as a glob that matches only itself. The corpus is a glob, and a set in
|
|
470
|
+
* `2.0.0-[rc]` otherwise reads as a character class that matches no directory, which reports
|
|
471
|
+
* the set as holding no document. A class of 1 character is the escape `fs.glob` honours: it
|
|
472
|
+
* reads `\` as a separator on Windows.
|
|
473
|
+
* ponytail: a `{` still opens a brace set, which no escape reaches through `fs.glob`; a set
|
|
474
|
+
* directory named with braces needs a config instead of a path.
|
|
475
|
+
*/
|
|
476
|
+
function globLiteral(name) {
|
|
477
|
+
return name.replace(/[[*?]/g, (c) => `[${c}]`);
|
|
305
478
|
}
|
|
306
479
|
|
|
307
480
|
/** Whether an immediate subdirectory carries an index.md, which is what makes a directory a root. */
|
|
@@ -329,10 +502,40 @@ export async function filesFor(entry, root) {
|
|
|
329
502
|
* Run every corpus entry. Returns the files checked and the issues found, each carrying the
|
|
330
503
|
* project-relative path it came from. Severity is the caller's: an issue is an issue here.
|
|
331
504
|
*/
|
|
505
|
+
/**
|
|
506
|
+
* The bytes a document set costs a reader, split by when they are paid. The index is loaded to
|
|
507
|
+
* answer any question and a page is loaded to answer 1, so the 2 numbers are different kinds of
|
|
508
|
+
* cost, and an author trimming the wrong one saves a reader nothing.
|
|
509
|
+
*
|
|
510
|
+
* Computed from the documents the caller has already read, so this adds no read of its own.
|
|
511
|
+
*/
|
|
512
|
+
export function setSizes(docs, docsets, rename = {}) {
|
|
513
|
+
const sets = new Map();
|
|
514
|
+
for (const { rel, raw } of docs) {
|
|
515
|
+
const id = renamedIdOf(rel, docsets, rename);
|
|
516
|
+
if (!id) continue;
|
|
517
|
+
const set = sets.get(id.docset) ?? { name: id.docset, indexBytes: 0, pages: 0, pageBytes: 0 };
|
|
518
|
+
const bytes = Buffer.byteLength(raw, 'utf8');
|
|
519
|
+
if (id.page === 'index') set.indexBytes += bytes;
|
|
520
|
+
else {
|
|
521
|
+
set.pages += 1;
|
|
522
|
+
set.pageBytes += bytes;
|
|
523
|
+
}
|
|
524
|
+
sets.set(id.docset, set);
|
|
525
|
+
}
|
|
526
|
+
return [...sets.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
527
|
+
}
|
|
528
|
+
|
|
332
529
|
export async function checkProject(config) {
|
|
333
530
|
const issues = [];
|
|
334
531
|
const checked = [];
|
|
335
532
|
const docs = [];
|
|
533
|
+
// A document the §5 word lists never reach. It is not an issue — nothing in it is wrong — but a
|
|
534
|
+
// run that says nothing about it reports a conformant corpus over rules that never ran.
|
|
535
|
+
const foreign = [];
|
|
536
|
+
// §3: a marked region the file's profile did not shape. Reported for the same reason: a run that
|
|
537
|
+
// names no exemption reads as a check of every line, and a region is an exemption anybody can write.
|
|
538
|
+
const regions = [];
|
|
336
539
|
for (const entry of config.corpus) {
|
|
337
540
|
const files = await filesFor(entry, config.root);
|
|
338
541
|
if (files.length === 0) {
|
|
@@ -343,10 +546,25 @@ export async function checkProject(config) {
|
|
|
343
546
|
const raw = await readFile(path.join(config.root, rel), 'utf8');
|
|
344
547
|
checked.push(rel);
|
|
345
548
|
docs.push({ rel, raw });
|
|
549
|
+
if (!lexiconApplies(raw)) foreign.push(rel);
|
|
550
|
+
for (const r of parseBlocks(raw).regions) regions.push({ file: rel, ...r });
|
|
346
551
|
for (const issue of bodyIssues(raw, profile)) issues.push({ file: rel, ...issue });
|
|
347
552
|
}
|
|
348
553
|
}
|
|
554
|
+
// A config from `readConfig` carries neither field: it already reads every set under its root.
|
|
555
|
+
const rename = config.rename ?? {};
|
|
556
|
+
// A companion's pages resolve references and are counted nowhere: the run reports on the set it
|
|
557
|
+
// was pointed at. `docsetCorpus` is the 1 place the shape of a set is written, so it is read here too.
|
|
558
|
+
const context = [];
|
|
559
|
+
for (const { name, dir } of config.companions ?? []) {
|
|
560
|
+
for (const entry of docsetCorpus('.')) {
|
|
561
|
+
for (const rel of await filesFor(entry, dir)) {
|
|
562
|
+
const page = rel.split(path.sep).join('/').slice(0, -3);
|
|
563
|
+
context.push({ set: name, page, raw: await readFile(path.join(dir, rel), 'utf8') });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
349
567
|
// §9 is the 1 rule that needs the whole corpus, so it runs once over the files already read.
|
|
350
|
-
if (config.docsets) issues.push(...linkIssues(docs, config.docsets));
|
|
351
|
-
return { checked, issues };
|
|
568
|
+
if (config.docsets) issues.push(...linkIssues(docs, config.docsets, { context, rename }));
|
|
569
|
+
return { checked, issues, foreign, regions, sets: config.docsets ? setSizes(docs, config.docsets, rename) : [] };
|
|
352
570
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gcf-aif-ext",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Game Context Framework for AI Factory",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"README.md"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"cnlp-kit": "^3.1
|
|
45
|
+
"cnlp-kit": "^3.9.1",
|
|
46
46
|
"yaml": "^2.5.1"
|
|
47
47
|
},
|
|
48
48
|
"bundleDependencies": [
|