ekohacks 0.3.0 → 0.4.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 +2 -0
- package/dist/cli.js +25 -4
- package/dist/logic/docs.js +126 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ The release command is built: the four stories in [`stories/`](stories/) are imp
|
|
|
10
10
|
|
|
11
11
|
The second command is `ekohacks docs check`, specified in [`stories/6-docs-check.md`](stories/6-docs-check.md): a gate that fails when a package's docs drift from its shipped exports map, born from the EkoLite 0.4.0 release shipping a wrong public surface. Its acceptance test is the same command run inside EkoLite.
|
|
12
12
|
|
|
13
|
+
`ekohacks docs sync` is the other half, specified in [`stories/8-docs-sync.md`](stories/8-docs-sync.md): it writes the edits the check can prove — the entry-point block, the prose counts — and scaffolds a stub page for an entry point the docs just gained, leaving the prose to a person. [`stories/9-docs-draft.md`](stories/9-docs-draft.md) specifies drafting that prose and opening a PR for a human to review; it is written down, not built.
|
|
14
|
+
|
|
13
15
|
## How this is built
|
|
14
16
|
|
|
15
17
|
- A tested core policy behind nullable infrastructure, in the style of [James Shore's Testing Without Mocks](https://www.jamesshore.com/v2/projects/nullables): real wrappers around `git`, `gh` and `npm`, each with a `createNull()` that answers with configurable responses and records what was asked of it. No mocks, no spies.
|
package/dist/cli.js
CHANGED
|
@@ -2,31 +2,36 @@
|
|
|
2
2
|
// The thin shell: read the repo's files, wire the real wrappers, print one line per
|
|
3
3
|
// check and per step, exit 0 only when the command ran to its end. Everything worth
|
|
4
4
|
// testing lives below.
|
|
5
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
6
7
|
import { createInterface } from 'node:readline/promises';
|
|
7
8
|
import { GhWrapper } from './infrastructure/gh.js';
|
|
8
9
|
import { GitWrapper } from './infrastructure/git.js';
|
|
9
10
|
import { NpmWrapper } from './infrastructure/npm.js';
|
|
10
11
|
import { ProcessRunner } from './infrastructure/process.js';
|
|
11
12
|
import { cut } from './logic/cut.js';
|
|
12
|
-
import { docsCheck } from './logic/docs.js';
|
|
13
|
+
import { docsCheck, docsSync } from './logic/docs.js';
|
|
13
14
|
import { preflight } from './logic/preflight.js';
|
|
14
15
|
import { release } from './logic/release.js';
|
|
15
16
|
import { ship } from './logic/ship.js';
|
|
16
17
|
const USAGE = [
|
|
17
18
|
'usage: ekohacks release [preflight|cut|ship] <version> [--yes]',
|
|
18
19
|
' ekohacks docs check',
|
|
20
|
+
' ekohacks docs sync [--dry-run]',
|
|
19
21
|
].join('\n');
|
|
22
|
+
const FLAGS = ['--yes', '--dry-run'];
|
|
20
23
|
const argv = process.argv.slice(2);
|
|
21
24
|
const yes = argv.includes('--yes');
|
|
22
|
-
const
|
|
25
|
+
const dryRun = argv.includes('--dry-run');
|
|
26
|
+
const [command, ...rest] = argv.filter((arg) => !FLAGS.includes(arg));
|
|
23
27
|
const printChecks = (checks) => {
|
|
24
28
|
for (const check of checks) {
|
|
25
29
|
console.log(check.passed ? ` ok ${check.name}` : ` FAIL ${check.name}: ${check.reason}`);
|
|
26
30
|
}
|
|
27
31
|
};
|
|
28
32
|
if (command === 'docs') {
|
|
29
|
-
|
|
33
|
+
const subject = rest[0];
|
|
34
|
+
if (rest.length !== 1 || (subject !== 'check' && subject !== 'sync')) {
|
|
30
35
|
console.error(USAGE);
|
|
31
36
|
process.exit(2);
|
|
32
37
|
}
|
|
@@ -46,6 +51,22 @@ if (command === 'docs') {
|
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
}
|
|
54
|
+
if (subject === 'sync') {
|
|
55
|
+
const { edits } = docsSync({ pkg: manifest.name, exports: manifest.exports, files });
|
|
56
|
+
if (edits.length === 0) {
|
|
57
|
+
console.log(' the docs are already in step');
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
for (const edit of edits) {
|
|
61
|
+
const verb = existsSync(edit.path) ? 'updated' : 'created';
|
|
62
|
+
if (!dryRun) {
|
|
63
|
+
mkdirSync(dirname(edit.path), { recursive: true });
|
|
64
|
+
writeFileSync(edit.path, edit.content);
|
|
65
|
+
}
|
|
66
|
+
console.log(` ${dryRun ? `would have ${verb}` : verb} ${edit.path}`);
|
|
67
|
+
}
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
49
70
|
const report = await docsCheck({
|
|
50
71
|
pkg: manifest.name,
|
|
51
72
|
exports: manifest.exports,
|
package/dist/logic/docs.js
CHANGED
|
@@ -50,9 +50,12 @@ const COUNT_WORDS = {
|
|
|
50
50
|
nine: 9,
|
|
51
51
|
ten: 10,
|
|
52
52
|
};
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
// One definition of what a claim looks like, read by the check and rewritten by the sync — two
|
|
54
|
+
// copies of it would be their own drift. Built fresh at each use: a shared global regex carries
|
|
55
|
+
// its lastIndex from call to call.
|
|
56
|
+
const COUNT_CLAIM_SOURCE = String.raw `\b(\d+|zero|one|two|three|four|five|six|seven|eight|nine|ten)[\s-]+entry[\s-]+points?\b`;
|
|
57
|
+
const countClaims = () => new RegExp(COUNT_CLAIM_SOURCE, 'gi');
|
|
58
|
+
const countClaimsIn = (content) => [...content.matchAll(countClaims())].map(([, claim = '']) => COUNT_WORDS[claim.toLowerCase()] ?? Number(claim));
|
|
56
59
|
// The drift detector as a policy: the exports map is the truth, the docs carry their
|
|
57
60
|
// claims in a block the tool owns, and every mismatch is a named check with the reason
|
|
58
61
|
// a human needs to fix it. The caller supplies file contents and the runner; the policy
|
|
@@ -108,3 +111,123 @@ export const docsCheck = async ({ pkg, exports, files, runner, }) => {
|
|
|
108
111
|
: { name: 'docs build', passed: false, reason: `${DOCS_BUILD_COMMAND} failed` });
|
|
109
112
|
return { checks };
|
|
110
113
|
};
|
|
114
|
+
// Walks the same markers blockRegions does, but rebuilds the document around each region so a
|
|
115
|
+
// rewrite can be handed back in place. An unclosed block ends the walk with the rest of the
|
|
116
|
+
// document untouched: the check already fails it by name, and a sync that guessed where the
|
|
117
|
+
// block ended would be guessing with someone's prose.
|
|
118
|
+
const mapBlocks = (content, rewrite) => {
|
|
119
|
+
let rest = content;
|
|
120
|
+
let out = '';
|
|
121
|
+
for (;;) {
|
|
122
|
+
const open = rest.indexOf(OPEN_MARKER);
|
|
123
|
+
if (open === -1) {
|
|
124
|
+
return out + rest;
|
|
125
|
+
}
|
|
126
|
+
const afterOpen = rest.slice(open + OPEN_MARKER.length);
|
|
127
|
+
const close = afterOpen.indexOf(CLOSE_MARKER);
|
|
128
|
+
if (close === -1) {
|
|
129
|
+
return out + rest;
|
|
130
|
+
}
|
|
131
|
+
out += rest.slice(0, open) + OPEN_MARKER + rewrite(afterOpen.slice(0, close)) + CLOSE_MARKER;
|
|
132
|
+
rest = afterOpen.slice(close + CLOSE_MARKER.length);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
// A namespace import is the one form the exports map alone can justify: it needs the specifier
|
|
136
|
+
// and nothing else. The local name is the last segment, camel-cased past any character an
|
|
137
|
+
// identifier cannot carry.
|
|
138
|
+
const importLineFor = (specifier) => {
|
|
139
|
+
const [first = specifier, ...rest] = (specifier.split('/').at(-1) ?? specifier).split(/[^a-zA-Z0-9]+/);
|
|
140
|
+
const local = [first, ...rest.map((part) => part.charAt(0).toUpperCase() + part.slice(1))].join('');
|
|
141
|
+
return `import * as ${local} from '${specifier}';`;
|
|
142
|
+
};
|
|
143
|
+
// Both edits to a block are line surgery on the specifiers it already lists: a line naming a
|
|
144
|
+
// specifier the exports no longer declare goes, and a specifier no line names arrives after the
|
|
145
|
+
// last one that survived. Lines naming another package are not the block's business — the check
|
|
146
|
+
// ignores them, and so does this.
|
|
147
|
+
const syncedBlock = (region, pkg, entries) => {
|
|
148
|
+
const kept = region
|
|
149
|
+
.split('\n')
|
|
150
|
+
.filter((line) => specifiersIn(line, pkg).every((specifier) => entries.includes(specifier)));
|
|
151
|
+
const documented = new Set(specifiersIn(kept.join('\n'), pkg));
|
|
152
|
+
const missing = entries.filter((entry) => !documented.has(entry));
|
|
153
|
+
const lastImport = kept.reduce((found, line, index) => (specifiersIn(line, pkg).length > 0 ? index : found), -1);
|
|
154
|
+
kept.splice(lastImport + 1, 0, ...missing.map(importLineFor));
|
|
155
|
+
return kept.join('\n');
|
|
156
|
+
};
|
|
157
|
+
// A page the tool can write without knowing anything it does not know: the import line it can
|
|
158
|
+
// derive, and a TODO everywhere prose belongs. The sidebar is code the sync will not touch, so
|
|
159
|
+
// the line a human has to add is spelled out rather than written.
|
|
160
|
+
const stubFor = (specifier) => {
|
|
161
|
+
const name = specifier.split('/').at(-1) ?? specifier;
|
|
162
|
+
return {
|
|
163
|
+
path: `docs/${name}.md`,
|
|
164
|
+
content: [
|
|
165
|
+
`# ${specifier}`,
|
|
166
|
+
'',
|
|
167
|
+
'```ts',
|
|
168
|
+
importLineFor(specifier),
|
|
169
|
+
'',
|
|
170
|
+
'// TODO: an example that runs.',
|
|
171
|
+
'```',
|
|
172
|
+
'',
|
|
173
|
+
'## What works today',
|
|
174
|
+
'',
|
|
175
|
+
'- TODO: what a reader can rely on today, not what is planned.',
|
|
176
|
+
'',
|
|
177
|
+
'<!-- TODO: add this page to the sidebar in docs/.vitepress/config.mts:',
|
|
178
|
+
` { text: '${name}', link: '/${name}' } -->`,
|
|
179
|
+
'',
|
|
180
|
+
].join('\n'),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
const wordForCount = (count) => Object.entries(COUNT_WORDS).find(([, value]) => value === count)?.[0];
|
|
184
|
+
// A claim is rewritten in the form it was written: a digit stays a digit, a word stays a word
|
|
185
|
+
// carrying the case it had. Past ten the table has no word, so the claim becomes a digit — the
|
|
186
|
+
// same number the check reads either way.
|
|
187
|
+
const countAs = (written, count) => {
|
|
188
|
+
const word = wordForCount(count);
|
|
189
|
+
if (/^\d+$/.test(written) || word === undefined) {
|
|
190
|
+
return String(count);
|
|
191
|
+
}
|
|
192
|
+
return written.charAt(0) === written.charAt(0).toUpperCase()
|
|
193
|
+
? word.charAt(0).toUpperCase() + word.slice(1)
|
|
194
|
+
: word;
|
|
195
|
+
};
|
|
196
|
+
const syncedCounts = (content, count) => content.replace(countClaims(), (claim) => {
|
|
197
|
+
const written = claim.split(/[\s-]/)[0] ?? '';
|
|
198
|
+
return countAs(written, count) + claim.slice(written.length);
|
|
199
|
+
});
|
|
200
|
+
// The mechanical half of the drift the check names: the same inputs, and instead of a report
|
|
201
|
+
// the files whose content should change, each as a whole new body. The shell does the writing,
|
|
202
|
+
// so the policy stays pure — and a file this would leave alone never reaches the caller, which
|
|
203
|
+
// is what makes a second run against a repo already in step write nothing.
|
|
204
|
+
export const docsSync = ({ pkg, exports, files, }) => {
|
|
205
|
+
const entries = entryPointsFrom(pkg, exports);
|
|
206
|
+
const scanned = files.filter((file) => !file.path.split('/').includes('.vitepress'));
|
|
207
|
+
const edits = [];
|
|
208
|
+
for (const file of scanned) {
|
|
209
|
+
const synced = mapBlocks(file.content, (region) => syncedBlock(region, pkg, entries));
|
|
210
|
+
const content = syncedCounts(synced, entries.length);
|
|
211
|
+
if (content !== file.content) {
|
|
212
|
+
edits.push({ path: file.path, content });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// What the docs declared before this run is the baseline a stub is new against. An unclosed
|
|
216
|
+
// block declares nothing readable and a repo with no block at all has no baseline, so neither
|
|
217
|
+
// gets scaffolded: both are failures the check already names, and guessing past them would
|
|
218
|
+
// stamp pages across a repo on the strength of a marker somebody forgot to close.
|
|
219
|
+
const readable = scanned
|
|
220
|
+
.map((file) => blockRegions(file.content))
|
|
221
|
+
.filter((block) => !block.unclosed && block.regions.length > 0);
|
|
222
|
+
const declared = new Set(readable.flatMap((block) => block.regions.flatMap((region) => specifiersIn(region, pkg))));
|
|
223
|
+
const existing = new Set(files.map((file) => file.path));
|
|
224
|
+
if (readable.length > 0) {
|
|
225
|
+
for (const entry of entries.filter((entry) => entry !== pkg && !declared.has(entry))) {
|
|
226
|
+
const stub = stubFor(entry);
|
|
227
|
+
if (!existing.has(stub.path)) {
|
|
228
|
+
edits.push(stub);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { edits };
|
|
233
|
+
};
|