ekohacks 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli.js +53 -6
- package/dist/logic/cut.js +6 -1
- package/dist/logic/docs.js +110 -0
- package/dist/logic/release.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ We run a distributed team shipping open source to npm with strict TDD and trunk
|
|
|
8
8
|
|
|
9
9
|
The release command is built: the four stories in [`stories/`](stories/) are implemented, each delivered red first — a failing test is committed and reviewed before any production code exists. The suite is the specification. The acceptance test for the whole is the next real EkoLite release, run with `ekohacks release <version>`.
|
|
10
10
|
|
|
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
|
+
|
|
11
13
|
## How this is built
|
|
12
14
|
|
|
13
15
|
- 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,29 +2,76 @@
|
|
|
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 { readFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
6
6
|
import { createInterface } from 'node:readline/promises';
|
|
7
7
|
import { GhWrapper } from './infrastructure/gh.js';
|
|
8
8
|
import { GitWrapper } from './infrastructure/git.js';
|
|
9
9
|
import { NpmWrapper } from './infrastructure/npm.js';
|
|
10
10
|
import { ProcessRunner } from './infrastructure/process.js';
|
|
11
11
|
import { cut } from './logic/cut.js';
|
|
12
|
+
import { docsCheck } from './logic/docs.js';
|
|
12
13
|
import { preflight } from './logic/preflight.js';
|
|
13
14
|
import { release } from './logic/release.js';
|
|
14
15
|
import { ship } from './logic/ship.js';
|
|
16
|
+
const USAGE = [
|
|
17
|
+
'usage: ekohacks release [preflight|cut|ship] <version> [--yes]',
|
|
18
|
+
' ekohacks docs check',
|
|
19
|
+
].join('\n');
|
|
15
20
|
const argv = process.argv.slice(2);
|
|
16
21
|
const yes = argv.includes('--yes');
|
|
17
22
|
const [command, ...rest] = argv.filter((arg) => arg !== '--yes');
|
|
23
|
+
const printChecks = (checks) => {
|
|
24
|
+
for (const check of checks) {
|
|
25
|
+
console.log(check.passed ? ` ok ${check.name}` : ` FAIL ${check.name}: ${check.reason}`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
if (command === 'docs') {
|
|
29
|
+
if (rest.length !== 1 || rest[0] !== 'check') {
|
|
30
|
+
console.error(USAGE);
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
if (!existsSync('package.json')) {
|
|
34
|
+
console.error('stopped: no package.json in this directory');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const manifest = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
38
|
+
const files = [];
|
|
39
|
+
if (existsSync('README.md')) {
|
|
40
|
+
files.push({ path: 'README.md', content: readFileSync('README.md', 'utf8') });
|
|
41
|
+
}
|
|
42
|
+
if (existsSync('docs')) {
|
|
43
|
+
for (const entry of readdirSync('docs', { recursive: true, encoding: 'utf8' }).sort()) {
|
|
44
|
+
if (entry.endsWith('.md')) {
|
|
45
|
+
files.push({ path: `docs/${entry}`, content: readFileSync(`docs/${entry}`, 'utf8') });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const report = await docsCheck({
|
|
50
|
+
pkg: manifest.name,
|
|
51
|
+
exports: manifest.exports,
|
|
52
|
+
files,
|
|
53
|
+
runner: ProcessRunner.create(),
|
|
54
|
+
});
|
|
55
|
+
printChecks(report.checks);
|
|
56
|
+
process.exit(report.checks.every((check) => check.passed) ? 0 : 1);
|
|
57
|
+
}
|
|
18
58
|
const first = rest[0];
|
|
19
59
|
const subcommand = first === 'preflight' || first === 'cut' || first === 'ship' ? first : undefined;
|
|
20
60
|
const version = subcommand === undefined ? first : rest[1];
|
|
21
61
|
if (command !== 'release' || version === undefined) {
|
|
22
|
-
console.error(
|
|
62
|
+
console.error(USAGE);
|
|
23
63
|
process.exit(2);
|
|
24
64
|
}
|
|
65
|
+
for (const file of ['CHANGELOG.md', 'package.json', 'package-lock.json']) {
|
|
66
|
+
if (!existsSync(file)) {
|
|
67
|
+
console.error(`stopped: no ${file} in this directory`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
25
71
|
const read = (file) => readFileSync(file, 'utf8');
|
|
26
72
|
const changelog = read('CHANGELOG.md');
|
|
27
|
-
const
|
|
73
|
+
const manifest = JSON.parse(read('package.json'));
|
|
74
|
+
const pkg = manifest.name;
|
|
28
75
|
const confirm = async (question) => {
|
|
29
76
|
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
30
77
|
const answer = await readline.question(`${question} (y/n) `);
|
|
@@ -45,6 +92,7 @@ if (subcommand === undefined) {
|
|
|
45
92
|
confirm,
|
|
46
93
|
narrate,
|
|
47
94
|
yes,
|
|
95
|
+
currentVersion: manifest.version,
|
|
48
96
|
});
|
|
49
97
|
if ('stopped' in result) {
|
|
50
98
|
console.error(`stopped: ${result.stopped}`);
|
|
@@ -77,9 +125,7 @@ const report = await preflight({
|
|
|
77
125
|
git: GitWrapper.create(),
|
|
78
126
|
runner: ProcessRunner.create(),
|
|
79
127
|
});
|
|
80
|
-
|
|
81
|
-
console.log(check.passed ? ` ok ${check.name}` : ` FAIL ${check.name}: ${check.reason}`);
|
|
82
|
-
}
|
|
128
|
+
printChecks(report.checks);
|
|
83
129
|
if (subcommand === 'preflight') {
|
|
84
130
|
process.exit(report.checks.every((check) => check.passed) ? 0 : 1);
|
|
85
131
|
}
|
|
@@ -91,6 +137,7 @@ const result = await cut({
|
|
|
91
137
|
npm: NpmWrapper.create(),
|
|
92
138
|
gh: GhWrapper.create(),
|
|
93
139
|
narrate,
|
|
140
|
+
currentVersion: manifest.version,
|
|
94
141
|
});
|
|
95
142
|
if ('stopped' in result) {
|
|
96
143
|
console.error(`stopped: ${result.stopped}`);
|
package/dist/logic/cut.js
CHANGED
|
@@ -7,11 +7,16 @@ import { NpmWrapper } from '../infrastructure/npm.js';
|
|
|
7
7
|
// release PR, wait for CI, merge on green. It refuses to start unless preflight passed,
|
|
8
8
|
// and every stop names the reason so a human can pick up by hand. The caller supplies the
|
|
9
9
|
// wrappers and hears each step through narrate.
|
|
10
|
-
export const cut = async ({ version, changelog, report, git, npm, gh, narrate, confirm = () => Promise.resolve(true), pollDelayMs = 15_000, }) => {
|
|
10
|
+
export const cut = async ({ version, changelog, report, git, npm, gh, narrate, confirm = () => Promise.resolve(true), currentVersion, pollDelayMs = 15_000, }) => {
|
|
11
11
|
const failed = report.checks.filter((check) => !check.passed);
|
|
12
12
|
if (failed.length > 0) {
|
|
13
13
|
return { stopped: `preflight failed: ${failed.map((check) => check.name).join(', ')}` };
|
|
14
14
|
}
|
|
15
|
+
if (currentVersion === version) {
|
|
16
|
+
return {
|
|
17
|
+
stopped: `package.json is already at ${version}: the cut looks finished, run ekohacks release ship ${version}`,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
15
20
|
const branch = `release/v${version}`;
|
|
16
21
|
if (await git.branchExists(branch)) {
|
|
17
22
|
return { stopped: `branch ${branch} already exists from an earlier cut` };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { ProcessRunner } from '../infrastructure/process.js';
|
|
2
|
+
const DOCS_BUILD_COMMAND = 'npm run docs:build';
|
|
3
|
+
const OPEN_MARKER = '<!-- ekohacks:entry-points -->';
|
|
4
|
+
const CLOSE_MARKER = '<!-- /ekohacks:entry-points -->';
|
|
5
|
+
// The public entry points an exports map declares, as the specifiers a consumer imports:
|
|
6
|
+
// "." is the bare package name, "./react" is pkg/react. A package with no exports map —
|
|
7
|
+
// or a conditions-only one, with no "." keys — has a single entry point: itself.
|
|
8
|
+
export const entryPointsFrom = (pkg, exports) => {
|
|
9
|
+
if (typeof exports !== 'object' || exports === null) {
|
|
10
|
+
return [pkg];
|
|
11
|
+
}
|
|
12
|
+
const subpaths = Object.keys(exports).filter((key) => key.startsWith('.'));
|
|
13
|
+
if (subpaths.length === 0) {
|
|
14
|
+
return [pkg];
|
|
15
|
+
}
|
|
16
|
+
return subpaths
|
|
17
|
+
.filter((key) => key !== './package.json' && !key.includes('*'))
|
|
18
|
+
.map((key) => (key === '.' ? pkg : `${pkg}/${key.slice(2)}`));
|
|
19
|
+
};
|
|
20
|
+
const blockRegions = (content) => {
|
|
21
|
+
const regions = [];
|
|
22
|
+
let rest = content;
|
|
23
|
+
for (;;) {
|
|
24
|
+
const open = rest.indexOf(OPEN_MARKER);
|
|
25
|
+
if (open === -1) {
|
|
26
|
+
return { regions, unclosed: false };
|
|
27
|
+
}
|
|
28
|
+
const afterOpen = rest.slice(open + OPEN_MARKER.length);
|
|
29
|
+
const close = afterOpen.indexOf(CLOSE_MARKER);
|
|
30
|
+
if (close === -1) {
|
|
31
|
+
return { regions, unclosed: true };
|
|
32
|
+
}
|
|
33
|
+
regions.push(afterOpen.slice(0, close));
|
|
34
|
+
rest = afterOpen.slice(close + CLOSE_MARKER.length);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const specifiersIn = (region, pkg) => [...region.matchAll(/from\s+['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/g)]
|
|
38
|
+
.map(([, fromSpecifier, bareSpecifier]) => fromSpecifier ?? bareSpecifier ?? '')
|
|
39
|
+
.filter((specifier) => specifier === pkg || specifier.startsWith(`${pkg}/`));
|
|
40
|
+
const COUNT_WORDS = {
|
|
41
|
+
zero: 0,
|
|
42
|
+
one: 1,
|
|
43
|
+
two: 2,
|
|
44
|
+
three: 3,
|
|
45
|
+
four: 4,
|
|
46
|
+
five: 5,
|
|
47
|
+
six: 6,
|
|
48
|
+
seven: 7,
|
|
49
|
+
eight: 8,
|
|
50
|
+
nine: 9,
|
|
51
|
+
ten: 10,
|
|
52
|
+
};
|
|
53
|
+
const countClaimsIn = (content) => [
|
|
54
|
+
...content.matchAll(/\b(\d+|zero|one|two|three|four|five|six|seven|eight|nine|ten)[\s-]+entry[\s-]+points?\b/gi),
|
|
55
|
+
].map(([, claim = '']) => COUNT_WORDS[claim.toLowerCase()] ?? Number(claim));
|
|
56
|
+
// The drift detector as a policy: the exports map is the truth, the docs carry their
|
|
57
|
+
// claims in a block the tool owns, and every mismatch is a named check with the reason
|
|
58
|
+
// a human needs to fix it. The caller supplies file contents and the runner; the policy
|
|
59
|
+
// only judges.
|
|
60
|
+
export const docsCheck = async ({ pkg, exports, files, runner, }) => {
|
|
61
|
+
const checks = [];
|
|
62
|
+
const scanned = files.filter((file) => !file.path.split('/').includes('.vitepress'));
|
|
63
|
+
const carriers = scanned.filter((file) => file.content.includes(OPEN_MARKER));
|
|
64
|
+
checks.push(carriers.length > 0
|
|
65
|
+
? { name: 'entry points declared', passed: true }
|
|
66
|
+
: {
|
|
67
|
+
name: 'entry points declared',
|
|
68
|
+
passed: false,
|
|
69
|
+
reason: `no docs file carries an ${OPEN_MARKER} block`,
|
|
70
|
+
});
|
|
71
|
+
const entries = entryPointsFrom(pkg, exports);
|
|
72
|
+
for (const file of carriers) {
|
|
73
|
+
const name = `entry points in ${file.path}`;
|
|
74
|
+
const { regions, unclosed } = blockRegions(file.content);
|
|
75
|
+
if (unclosed) {
|
|
76
|
+
checks.push({ name, passed: false, reason: 'unclosed ekohacks:entry-points block' });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const documented = new Set(regions.flatMap((region) => specifiersIn(region, pkg)));
|
|
80
|
+
const notListed = entries.filter((entry) => !documented.has(entry));
|
|
81
|
+
const notExported = [...documented].filter((specifier) => !entries.includes(specifier));
|
|
82
|
+
const reasons = [
|
|
83
|
+
...(notListed.length > 0 ? [`not listed: ${notListed.join(', ')}`] : []),
|
|
84
|
+
...(notExported.length > 0 ? [`not in exports: ${notExported.join(', ')}`] : []),
|
|
85
|
+
];
|
|
86
|
+
checks.push(reasons.length > 0
|
|
87
|
+
? { name, passed: false, reason: reasons.join('; ') }
|
|
88
|
+
: { name, passed: true });
|
|
89
|
+
}
|
|
90
|
+
for (const file of scanned) {
|
|
91
|
+
const claims = countClaimsIn(file.content);
|
|
92
|
+
if (claims.length === 0) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const name = `entry count in ${file.path}`;
|
|
96
|
+
const wrong = claims.filter((claim) => claim !== entries.length);
|
|
97
|
+
checks.push(wrong.length > 0
|
|
98
|
+
? {
|
|
99
|
+
name,
|
|
100
|
+
passed: false,
|
|
101
|
+
reason: `claims ${wrong.join(' and ')} entry points, exports has ${entries.length}`,
|
|
102
|
+
}
|
|
103
|
+
: { name, passed: true });
|
|
104
|
+
}
|
|
105
|
+
const build = await runner.run(DOCS_BUILD_COMMAND);
|
|
106
|
+
checks.push(build.exitCode === 0
|
|
107
|
+
? { name: 'docs build', passed: true }
|
|
108
|
+
: { name: 'docs build', passed: false, reason: `${DOCS_BUILD_COMMAND} failed` });
|
|
109
|
+
return { checks };
|
|
110
|
+
};
|
package/dist/logic/release.js
CHANGED
|
@@ -9,7 +9,7 @@ import { ProcessRunner } from '../infrastructure/process.js';
|
|
|
9
9
|
// the first failure with that stage's own reason. No stage's logic lives here — this
|
|
10
10
|
// policy only composes the three and decides which pauses --yes may skip: the merge and
|
|
11
11
|
// the Release. The gate always asks.
|
|
12
|
-
export const release = async ({ version, changelog, lockfile, pkg, git, npm, gh, runner, confirm, narrate, yes = false, pollDelayMs, findRunAttempts, registryAttempts, }) => {
|
|
12
|
+
export const release = async ({ version, changelog, lockfile, pkg, git, npm, gh, runner, confirm, narrate, yes = false, currentVersion, pollDelayMs, findRunAttempts, registryAttempts, }) => {
|
|
13
13
|
const report = await preflight({ version, changelog, npm, pkg, git, lockfile, runner });
|
|
14
14
|
for (const check of report.checks) {
|
|
15
15
|
narrate(check.passed ? `ok ${check.name}` : `FAIL ${check.name}: ${check.reason}`);
|
|
@@ -24,6 +24,7 @@ export const release = async ({ version, changelog, lockfile, pkg, git, npm, gh,
|
|
|
24
24
|
gh,
|
|
25
25
|
narrate,
|
|
26
26
|
confirm: skippable,
|
|
27
|
+
currentVersion,
|
|
27
28
|
pollDelayMs,
|
|
28
29
|
});
|
|
29
30
|
if ('stopped' in cutResult) {
|