ekohacks 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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('usage: ekohacks release [preflight|cut|ship] <version> [--yes]');
62
+ console.error(USAGE);
23
63
  process.exit(2);
24
64
  }
25
65
  for (const file of ['CHANGELOG.md', 'package.json', 'package-lock.json']) {
@@ -85,9 +125,7 @@ const report = await preflight({
85
125
  git: GitWrapper.create(),
86
126
  runner: ProcessRunner.create(),
87
127
  });
88
- for (const check of report.checks) {
89
- console.log(check.passed ? ` ok ${check.name}` : ` FAIL ${check.name}: ${check.reason}`);
90
- }
128
+ printChecks(report.checks);
91
129
  if (subcommand === 'preflight') {
92
130
  process.exit(report.checks.every((check) => check.passed) ? 0 : 1);
93
131
  }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ekohacks",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The EkoHacks CLI: our everyday operations, automated one small command at a time.",
5
5
  "repository": {
6
6
  "type": "git",