ekohacks 0.1.1 → 0.3.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 +44 -5
- package/dist/infrastructure/git.js +9 -1
- package/dist/logic/docs.js +110 -0
- package/dist/logic/release.js +1 -0
- package/dist/logic/ship.js +12 -5
- 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,24 +2,64 @@
|
|
|
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 } 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
|
}
|
|
25
65
|
for (const file of ['CHANGELOG.md', 'package.json', 'package-lock.json']) {
|
|
@@ -66,6 +106,7 @@ if (subcommand === 'ship') {
|
|
|
66
106
|
changelog,
|
|
67
107
|
pkg,
|
|
68
108
|
gh: GhWrapper.create(),
|
|
109
|
+
git: GitWrapper.create(),
|
|
69
110
|
npm: NpmWrapper.create(),
|
|
70
111
|
confirm,
|
|
71
112
|
narrate,
|
|
@@ -85,9 +126,7 @@ const report = await preflight({
|
|
|
85
126
|
git: GitWrapper.create(),
|
|
86
127
|
runner: ProcessRunner.create(),
|
|
87
128
|
});
|
|
88
|
-
|
|
89
|
-
console.log(check.passed ? ` ok ${check.name}` : ` FAIL ${check.name}: ${check.reason}`);
|
|
90
|
-
}
|
|
129
|
+
printChecks(report.checks);
|
|
91
130
|
if (subcommand === 'preflight') {
|
|
92
131
|
process.exit(report.checks.every((check) => check.passed) ? 0 : 1);
|
|
93
132
|
}
|
|
@@ -11,12 +11,13 @@ export class GitWrapper {
|
|
|
11
11
|
return stdout;
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
|
-
static createNull({ branch = 'main', dirty = false, behindOrigin = false, existingBranches = [], } = {}) {
|
|
14
|
+
static createNull({ branch = 'main', dirty = false, behindOrigin = false, existingBranches = [], versionOnMain = '0.0.0', } = {}) {
|
|
15
15
|
const outputs = {
|
|
16
16
|
'rev-parse --abbrev-ref HEAD': `${branch}\n`,
|
|
17
17
|
'status --porcelain': dirty ? ' M some-file.ts\n' : '',
|
|
18
18
|
'rev-parse main': behindOrigin ? 'local\n' : 'shared\n',
|
|
19
19
|
'rev-parse origin/main': 'shared\n',
|
|
20
|
+
'show origin/main:package.json': `${JSON.stringify({ version: versionOnMain })}\n`,
|
|
20
21
|
};
|
|
21
22
|
for (const existing of existingBranches) {
|
|
22
23
|
outputs[`branch --list ${existing}`] = ` ${existing}\n`;
|
|
@@ -38,6 +39,13 @@ export class GitWrapper {
|
|
|
38
39
|
async branchExists(branch) {
|
|
39
40
|
return (await this.runGit(['branch', '--list', branch])).trim() !== '';
|
|
40
41
|
}
|
|
42
|
+
// The version main carries on origin, not in this checkout: the Release targets
|
|
43
|
+
// GitHub's main, so a stale local package.json must not answer for it.
|
|
44
|
+
async versionOnMain() {
|
|
45
|
+
await this.runGit(['fetch', 'origin', 'main']);
|
|
46
|
+
const manifest = await this.runGit(['show', 'origin/main:package.json']);
|
|
47
|
+
return JSON.parse(manifest).version;
|
|
48
|
+
}
|
|
41
49
|
async mainInSyncWithOrigin() {
|
|
42
50
|
await this.runGit(['fetch', 'origin', 'main']);
|
|
43
51
|
const local = (await this.runGit(['rev-parse', 'main'])).trim();
|
|
@@ -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
package/dist/logic/ship.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
2
|
import { changelogEntryFor } from './changelog.js';
|
|
3
3
|
import { GhWrapper } from '../infrastructure/gh.js';
|
|
4
|
+
import { GitWrapper } from '../infrastructure/git.js';
|
|
4
5
|
import { NpmWrapper } from '../infrastructure/npm.js';
|
|
5
|
-
// The tail of RELEASING.md as a policy: cut
|
|
6
|
-
// gate, follow the run to green, and only
|
|
7
|
-
//
|
|
8
|
-
// confirm answering yes.
|
|
9
|
-
export const ship = async ({ version, changelog, pkg, gh, npm, confirm, confirmRelease = () => Promise.resolve(true), narrate, pollDelayMs = 15_000, findRunAttempts = 8, registryAttempts = 20, workflow = 'publish.yml', }) => {
|
|
6
|
+
// The tail of RELEASING.md as a policy: refuse a cut that has not landed on main, cut
|
|
7
|
+
// the GitHub Release, approve the publish gate, follow the run to green, and only
|
|
8
|
+
// report success once the registry serves the version. The one human decision stays
|
|
9
|
+
// human: the gate is never approved without the confirm answering yes.
|
|
10
|
+
export const ship = async ({ version, changelog, pkg, gh, git, npm, confirm, confirmRelease = () => Promise.resolve(true), narrate, pollDelayMs = 15_000, findRunAttempts = 8, registryAttempts = 20, workflow = 'publish.yml', }) => {
|
|
11
|
+
const onMain = await git.versionOnMain();
|
|
12
|
+
if (onMain !== version) {
|
|
13
|
+
return {
|
|
14
|
+
stopped: `main carries ${onMain ?? 'no version'}, not ${version}: the cut looks unfinished, run ekohacks release cut ${version}`,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
10
17
|
const tag = `v${version}`;
|
|
11
18
|
if (!(await confirmRelease(`cut release ${tag}?`))) {
|
|
12
19
|
return { stopped: 'release not approved' };
|