@isonimus/stele 0.1.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/.claude/commands/adr.md +43 -0
- package/.claude/commands/audit.md +25 -0
- package/.claude/commands/init-method.md +105 -0
- package/.claude/commands/remember.md +61 -0
- package/.claude/commands/slice.md +72 -0
- package/.claude/commands/wrap-up.md +30 -0
- package/.claude/hooks/pre-commit +29 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +48 -0
- package/scripts/build-index.mjs +129 -0
- package/scripts/init-method.mjs +359 -0
- package/scripts/lint-docs.mjs +463 -0
- package/scripts/migrate-adrs.mjs +218 -0
- package/scripts/scan-legacy.mjs +266 -0
- package/templates/CLAUDE.md +114 -0
- package/templates/LEDGER.md +24 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generates adr/INDEX.md from ADR frontmatter (ADR-0001: every file is immutable or
|
|
3
|
+
// generated; this one is generated). It is never hand-edited — the pre-commit hook
|
|
4
|
+
// regenerates it and fails if the working copy is stale, so the index cannot drift from
|
|
5
|
+
// the corpus it summarises.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/build-index.mjs [repo-root] (default: cwd) — writes adr/INDEX.md
|
|
8
|
+
// --check — exit 1 if INDEX.md is out of date
|
|
9
|
+
// --stdout — print, don't write
|
|
10
|
+
//
|
|
11
|
+
// Zero dependencies, plain Node ESM — same rationale as lint-docs.mjs (ADR-0003).
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { loadDocs } from './lint-docs.mjs';
|
|
16
|
+
|
|
17
|
+
const pad = (v) => String(v).trim().padStart(4, '0');
|
|
18
|
+
|
|
19
|
+
/** Escape a cell so a title containing `|` cannot break the markdown table. */
|
|
20
|
+
const cell = (s) => String(s).replace(/\|/g, '\\|');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Builds the INDEX.md text from a repo's ADR corpus. Pure: takes loaded docs, returns a
|
|
24
|
+
* string, touches no filesystem — so the same function serves both write and --check.
|
|
25
|
+
*/
|
|
26
|
+
export function renderIndex(docs) {
|
|
27
|
+
const adrs = docs
|
|
28
|
+
.filter((d) => d.ok && d.data.id !== undefined)
|
|
29
|
+
.map((d) => ({
|
|
30
|
+
id: pad(d.data.id),
|
|
31
|
+
title: d.data.title ?? '',
|
|
32
|
+
type: d.data.type ?? '',
|
|
33
|
+
status: d.data.status ?? '',
|
|
34
|
+
supersedes: (d.data.supersedes ?? []).map(pad),
|
|
35
|
+
superseded_by: (d.data.superseded_by ?? []).map(pad),
|
|
36
|
+
}))
|
|
37
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
38
|
+
|
|
39
|
+
const byType = (t) => adrs.filter((a) => a.type === t);
|
|
40
|
+
const active = (list) => list.filter((a) => a.status !== 'superseded');
|
|
41
|
+
|
|
42
|
+
const out = [];
|
|
43
|
+
out.push('# ADR Index');
|
|
44
|
+
out.push('');
|
|
45
|
+
out.push('<!-- GENERATED by scripts/build-index.mjs — do not edit by hand (ADR-0001).');
|
|
46
|
+
out.push(' Regenerate with `npm run index`; the pre-commit hook keeps it current. -->');
|
|
47
|
+
out.push('');
|
|
48
|
+
out.push(`${adrs.length} decision(s): ${byType('architecture').length} architecture, ` +
|
|
49
|
+
`${byType('slice').length} slice, ${byType('batch').length} batch.`);
|
|
50
|
+
|
|
51
|
+
const section = (heading, list) => {
|
|
52
|
+
out.push('');
|
|
53
|
+
out.push(`## ${heading}`);
|
|
54
|
+
out.push('');
|
|
55
|
+
if (!list.length) {
|
|
56
|
+
out.push('_None._');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
out.push('| id | title | status |');
|
|
60
|
+
out.push('| --- | --- | --- |');
|
|
61
|
+
for (const a of list) {
|
|
62
|
+
out.push(`| ${a.id} | ${cell(a.title)} | ${a.status} |`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
section('Architecture', active(byType('architecture')));
|
|
67
|
+
section('Slices', active(byType('slice')));
|
|
68
|
+
const batches = active(byType('batch'));
|
|
69
|
+
if (batches.length) section('Batches', batches);
|
|
70
|
+
|
|
71
|
+
// Supersession graph — every edge, derived from superseded_by. Rule 4 guarantees these
|
|
72
|
+
// are bidirectional in a green corpus, so one direction is enough to draw the graph.
|
|
73
|
+
const edges = [];
|
|
74
|
+
for (const a of adrs) {
|
|
75
|
+
for (const target of a.superseded_by) edges.push([a.id, target]);
|
|
76
|
+
}
|
|
77
|
+
edges.sort((x, y) => x[0].localeCompare(y[0]) || x[1].localeCompare(y[1]));
|
|
78
|
+
|
|
79
|
+
out.push('');
|
|
80
|
+
out.push('## Supersession');
|
|
81
|
+
out.push('');
|
|
82
|
+
if (!edges.length) {
|
|
83
|
+
out.push('_None._');
|
|
84
|
+
} else {
|
|
85
|
+
const titleOf = new Map(adrs.map((a) => [a.id, a.title]));
|
|
86
|
+
for (const [from, to] of edges) {
|
|
87
|
+
out.push(`- ${from} → ${to} (${cell(titleOf.get(to) ?? 'unknown')})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
out.push('');
|
|
92
|
+
return out.join('\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function indexPath(root) {
|
|
96
|
+
const dir = ['adr', 'slices'].map((d) => join(root, d)).find(existsSync) ?? join(root, 'adr');
|
|
97
|
+
return join(dir, 'INDEX.md');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function main(argv) {
|
|
101
|
+
const check = argv.includes('--check');
|
|
102
|
+
const toStdout = argv.includes('--stdout');
|
|
103
|
+
const root = argv.find((a) => !a.startsWith('--')) ?? process.cwd();
|
|
104
|
+
|
|
105
|
+
const text = renderIndex(loadDocs(root));
|
|
106
|
+
const path = indexPath(root);
|
|
107
|
+
|
|
108
|
+
if (toStdout) {
|
|
109
|
+
process.stdout.write(text);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (check) {
|
|
114
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
115
|
+
if (current !== text) {
|
|
116
|
+
console.error(`${path} is out of date — run \`npm run index\` and commit the result.`);
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
writeFileSync(path, text);
|
|
123
|
+
console.log(`wrote ${path}`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
128
|
+
process.exit(main(process.argv.slice(2)));
|
|
129
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Installs the method kit into a git repo (ADR-0006). Dry-run by default.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/init-method.mjs <repo-root> [--apply] [--check] [--update]
|
|
5
|
+
//
|
|
6
|
+
// The load-bearing rule lives in installHook(): the pre-commit hook is linked ONLY
|
|
7
|
+
// against a corpus the linter calls clean. An unwired scripts/*-verify.mjs is an R11
|
|
8
|
+
// error, so a repo can be red on arrival — and a hook installed on a red corpus blocks
|
|
9
|
+
// every commit, which is the tool bricking the repo it was meant to protect.
|
|
10
|
+
//
|
|
11
|
+
// The linter always runs against the REPO ROOT. Pointed at a subdirectory holding no
|
|
12
|
+
// documents it would report "0 document(s) — ok" (the reason rule 10 exists), certifying
|
|
13
|
+
// an install that checks nothing.
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, lstatSync, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { join, dirname, relative } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import { lint, loadDocs } from './lint-docs.mjs';
|
|
20
|
+
import { renderIndex } from './build-index.mjs';
|
|
21
|
+
|
|
22
|
+
const TOOLKIT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
23
|
+
|
|
24
|
+
/** Machinery vendored into every installed repo: target path ← toolkit path. Copies
|
|
25
|
+
* rather than symlinks, because a symlink into this checkout resolves on one machine
|
|
26
|
+
* only. These must stay byte-identical — a locally edited linter is a silently
|
|
27
|
+
* different linter, so `--check` calls any difference a problem. */
|
|
28
|
+
const VENDORED = [
|
|
29
|
+
['scripts/lint-docs.mjs', 'scripts/lint-docs.mjs'],
|
|
30
|
+
['scripts/build-index.mjs', 'scripts/build-index.mjs'],
|
|
31
|
+
['.claude/hooks/pre-commit', '.claude/hooks/pre-commit'],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const COMMANDS_DIR = '.claude/commands';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The slash commands, vendored too (ADR-0007) — same target path as toolkit path.
|
|
38
|
+
*
|
|
39
|
+
* Read from disk rather than listed, so a new command reaches installed repos without
|
|
40
|
+
* anyone remembering to extend an array here.
|
|
41
|
+
*/
|
|
42
|
+
const commandFiles = (toolkit) =>
|
|
43
|
+
readdirSync(join(toolkit, COMMANDS_DIR))
|
|
44
|
+
.filter((name) => name.endsWith('.md'))
|
|
45
|
+
.sort()
|
|
46
|
+
.map((name) => `${COMMANDS_DIR}/${name}`);
|
|
47
|
+
|
|
48
|
+
/** Scaffolded once and never overwritten: target path ← template path. */
|
|
49
|
+
const SCAFFOLD = [
|
|
50
|
+
['CLAUDE.md', 'templates/CLAUDE.md'],
|
|
51
|
+
['LEDGER.md', 'templates/LEDGER.md'],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** What .git/hooks/pre-commit must point at to count as installed. */
|
|
55
|
+
const HOOK_LINK_TARGET = '../../.claude/hooks/pre-commit';
|
|
56
|
+
|
|
57
|
+
/** A repo using the `pre-commit` framework gets the checks composed into its config
|
|
58
|
+
* instead of a symlink, because that framework owns the same file (ADR-0008). */
|
|
59
|
+
const FRAMEWORK_CONFIG = '.pre-commit-config.yaml';
|
|
60
|
+
|
|
61
|
+
/** Identifies our block on re-runs, so composing is idempotent. */
|
|
62
|
+
const FRAMEWORK_HOOK_ID = 'stele-docs';
|
|
63
|
+
|
|
64
|
+
/** Appended verbatim. Mirrors .claude/hooks/pre-commit — the same two commands. */
|
|
65
|
+
const FRAMEWORK_BLOCK = `
|
|
66
|
+
# Doc invariants (stele:ADR-0003, composed by /init-method per stele:ADR-0008).
|
|
67
|
+
# Zero-dependency and language: system, so there is nothing to install but node.
|
|
68
|
+
- repo: local
|
|
69
|
+
hooks:
|
|
70
|
+
- id: ${FRAMEWORK_HOOK_ID}
|
|
71
|
+
name: doc invariants hold
|
|
72
|
+
entry: node scripts/lint-docs.mjs .
|
|
73
|
+
language: system
|
|
74
|
+
pass_filenames: false
|
|
75
|
+
always_run: true
|
|
76
|
+
- id: stele-index
|
|
77
|
+
name: adr/INDEX.md matches the corpus
|
|
78
|
+
entry: node scripts/build-index.mjs --check .
|
|
79
|
+
language: system
|
|
80
|
+
pass_filenames: false
|
|
81
|
+
always_run: true
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const read = (path) => readFileSync(path, 'utf8');
|
|
85
|
+
|
|
86
|
+
/** existsSync follows symlinks, so a broken link reads as absent — the silent-vanish
|
|
87
|
+
* state ADR-0006 names. lstat is what distinguishes the two. */
|
|
88
|
+
function isSymlink(path) {
|
|
89
|
+
try {
|
|
90
|
+
return lstatSync(path).isSymbolicLink();
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The linter's error findings for a repo.
|
|
97
|
+
* Always the repo ROOT: pointed at a subdirectory holding no documents the linter
|
|
98
|
+
* reports "0 document(s) — ok", certifying an install that checks nothing (rule 10). */
|
|
99
|
+
const lintErrors = (target) => lint(target).findings.filter((f) => f.severity === 'error');
|
|
100
|
+
|
|
101
|
+
/** True when the target's copy is byte-identical to the toolkit's. */
|
|
102
|
+
const matches = (targetPath, toolkitPath) =>
|
|
103
|
+
existsSync(targetPath) && read(targetPath) === read(toolkitPath);
|
|
104
|
+
|
|
105
|
+
function vendor({ target, toolkit, apply, report }) {
|
|
106
|
+
for (const [dest, src] of VENDORED) {
|
|
107
|
+
const to = join(target, dest);
|
|
108
|
+
const from = join(toolkit, src);
|
|
109
|
+
if (matches(to, from)) {
|
|
110
|
+
report('ok', to, 'current');
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const verb = existsSync(to) ? 'update' : 'copy';
|
|
114
|
+
if (!apply) {
|
|
115
|
+
report('would', to, `${verb} from toolkit`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
119
|
+
copyFileSync(from, to);
|
|
120
|
+
report('wrote', to, `${verb}d from toolkit`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Slash commands, which are prose and therefore adaptable (ADR-0007).
|
|
126
|
+
*
|
|
127
|
+
* Copy-if-absent, unlike vendor(): a repo that has tailored `/slice` to its own workflow
|
|
128
|
+
* must not have that overwritten by an install. `--update` is the explicit way to take
|
|
129
|
+
* the toolkit's version back.
|
|
130
|
+
*/
|
|
131
|
+
function vendorCommands({ target, toolkit, apply, force, report }) {
|
|
132
|
+
for (const path of commandFiles(toolkit)) {
|
|
133
|
+
const to = join(target, path);
|
|
134
|
+
const from = join(toolkit, path);
|
|
135
|
+
if (matches(to, from)) {
|
|
136
|
+
report('ok', to, 'current');
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (existsSync(to) && !force) {
|
|
140
|
+
report('keep', to, 'differs from the toolkit — left as it is; `--update` takes the toolkit version');
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const verb = existsSync(to) ? 'overwrite' : 'copy';
|
|
144
|
+
if (!apply) {
|
|
145
|
+
report('would', to, `${verb} from toolkit`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
149
|
+
copyFileSync(from, to);
|
|
150
|
+
report('wrote', to, `${verb === 'copy' ? 'copied' : 'overwritten'} from toolkit`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function scaffold({ target, toolkit, apply, report }) {
|
|
155
|
+
const adr = join(target, 'adr');
|
|
156
|
+
if (!existsSync(adr)) {
|
|
157
|
+
if (apply) mkdirSync(adr, { recursive: true });
|
|
158
|
+
report(apply ? 'wrote' : 'would', adr, 'create adr/');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const [dest, src] of SCAFFOLD) {
|
|
162
|
+
const to = join(target, dest);
|
|
163
|
+
if (existsSync(to)) {
|
|
164
|
+
// A repo's own conventions outrank a template; never merge, never overwrite.
|
|
165
|
+
report('keep', to, 'already exists — left as it is');
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!apply) {
|
|
169
|
+
report('would', to, `scaffold from ${src}`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
writeFileSync(to, read(join(toolkit, src)));
|
|
173
|
+
report('wrote', to, `scaffolded from ${src} — fill its {{PLACEHOLDER}} fields`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildIndex({ target, apply, report }) {
|
|
178
|
+
const path = join(target, 'adr', 'INDEX.md');
|
|
179
|
+
const docs = loadDocs(target).filter((d) => d.ok);
|
|
180
|
+
const wanted = renderIndex(docs);
|
|
181
|
+
if (existsSync(path) && read(path) === wanted) return report('ok', path, 'current');
|
|
182
|
+
if (!apply) return report('would', path, 'generate index');
|
|
183
|
+
writeFileSync(path, wanted);
|
|
184
|
+
report('wrote', path, `generated — ${docs.length} decision(s)`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Links the hook, but only onto a clean corpus.
|
|
189
|
+
*
|
|
190
|
+
* The refusal is the decision (ADR-0006). A warning here would be a rule enforced by
|
|
191
|
+
* memory, which is the failure this whole kit exists to fix.
|
|
192
|
+
*/
|
|
193
|
+
function installHook({ target, apply, report }) {
|
|
194
|
+
// A dry run over a repo with no adr/ cannot judge the corpus: the linter would report
|
|
195
|
+
// R10 "no adr/ — is this the repo root?" against a directory --apply creates two steps
|
|
196
|
+
// earlier. Refusing on that would be a plan that contradicts what applying does.
|
|
197
|
+
if (!apply && !existsSync(join(target, 'adr'))) {
|
|
198
|
+
return report('would', join(target, '.git', 'hooks', 'pre-commit'), 'decide once adr/ exists — a dry run cannot check a corpus that has not been scaffolded yet');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const errors = lintErrors(target);
|
|
202
|
+
if (errors.length > 0) {
|
|
203
|
+
for (const f of errors) report('problem', f.path, f.message);
|
|
204
|
+
report('problem', target, `${errors.length} lint error(s) — REFUSING to install the pre-commit hook. A hook on a red corpus blocks every commit. Fix these (an unwired scripts/*-verify.mjs is wired by adding it to package.json), then re-run.`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (existsSync(join(target, FRAMEWORK_CONFIG))) return composeHook({ target, apply, report });
|
|
209
|
+
|
|
210
|
+
const path = join(target, '.git', 'hooks', 'pre-commit');
|
|
211
|
+
if (isSymlink(path) && readlinkSync(path) === HOOK_LINK_TARGET && existsSync(path)) {
|
|
212
|
+
return report('ok', path, 'hook installed');
|
|
213
|
+
}
|
|
214
|
+
if (existsSync(path) && !isSymlink(path)) {
|
|
215
|
+
return report('problem', path, 'a hook already exists here and is not ours — left untouched.');
|
|
216
|
+
}
|
|
217
|
+
if (!apply) return report('would', path, `link → ${HOOK_LINK_TARGET}`);
|
|
218
|
+
|
|
219
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
220
|
+
if (isSymlink(path)) unlinkSync(path);
|
|
221
|
+
symlinkSync(HOOK_LINK_TARGET, path);
|
|
222
|
+
report('wrote', path, `linked → ${HOOK_LINK_TARGET}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Joins the pre-commit framework instead of taking .git/hooks/pre-commit (ADR-0008).
|
|
227
|
+
*
|
|
228
|
+
* A symlink here would work until the next `pre-commit install`, which replaces the file
|
|
229
|
+
* with no error and takes the doc checks with it — a guarantee a third party can revoke
|
|
230
|
+
* silently is not a guarantee.
|
|
231
|
+
*/
|
|
232
|
+
function composeHook({ target, apply, report }) {
|
|
233
|
+
const path = join(target, FRAMEWORK_CONFIG);
|
|
234
|
+
const config = read(path);
|
|
235
|
+
|
|
236
|
+
// Refuse rather than guess: this is a textual append into a file we do not own.
|
|
237
|
+
if (!/^repos:/m.test(config)) {
|
|
238
|
+
return report('problem', path, 'no top-level `repos:` key — unrecognised shape, refusing to edit it. Add the stele-docs block by hand.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (config.includes(FRAMEWORK_HOOK_ID)) report('ok', path, 'doc checks composed into the framework');
|
|
242
|
+
else if (!apply) report('would', path, 'append the doc checks as a `repo: local` block');
|
|
243
|
+
else {
|
|
244
|
+
writeFileSync(path, `${config.replace(/\n*$/, '\n')}${FRAMEWORK_BLOCK}`);
|
|
245
|
+
report('wrote', path, 'appended the doc checks as a `repo: local` block');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
frameworkInstalled({ target, report });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The framework's own dispatcher. Configured-but-not-installed means nothing runs at
|
|
252
|
+
* all, while the config still reads as protection. */
|
|
253
|
+
function frameworkInstalled({ target, report }) {
|
|
254
|
+
const hook = join(target, '.git', 'hooks', 'pre-commit');
|
|
255
|
+
if (existsSync(hook)) report('ok', hook, 'the framework dispatcher is installed');
|
|
256
|
+
else report('problem', hook, 'the pre-commit framework is configured but never installed — no hook runs at all, including its own. Run `pre-commit install`.');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function check({ target, toolkit, report }) {
|
|
260
|
+
for (const [dest, src] of VENDORED) {
|
|
261
|
+
const to = join(target, dest);
|
|
262
|
+
if (!existsSync(to)) report('problem', to, 'missing — run /init-method --apply');
|
|
263
|
+
else if (!matches(to, join(toolkit, src))) report('problem', to, 'drifted from the toolkit — run /init-method --update');
|
|
264
|
+
else report('ok', to, 'current');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Commands are prose a repo may legitimately adapt, so their drift is informational
|
|
268
|
+
// (ADR-0007) — reported so it is visible, never counted against a clean check.
|
|
269
|
+
for (const path of commandFiles(toolkit)) {
|
|
270
|
+
const to = join(target, path);
|
|
271
|
+
if (!existsSync(to)) report('missing', to, 'not installed — run /init-method --apply');
|
|
272
|
+
else if (!matches(to, join(toolkit, path))) report('local', to, 'differs from the toolkit — kept; `--update` takes the toolkit version');
|
|
273
|
+
else report('ok', to, 'current');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The framework's presence is the single discriminator, so --check cannot disagree
|
|
277
|
+
// with --apply about which install shape is in force (ADR-0008).
|
|
278
|
+
const framework = join(target, FRAMEWORK_CONFIG);
|
|
279
|
+
const hook = join(target, '.git', 'hooks', 'pre-commit');
|
|
280
|
+
if (existsSync(framework)) {
|
|
281
|
+
if (read(framework).includes(FRAMEWORK_HOOK_ID)) report('ok', framework, 'doc checks composed into the framework');
|
|
282
|
+
else report('problem', framework, 'the pre-commit framework runs here but not the doc checks — run /init-method --apply');
|
|
283
|
+
frameworkInstalled({ target, report });
|
|
284
|
+
} else if (!isSymlink(hook)) report('problem', hook, 'no hook installed — nothing checks commits');
|
|
285
|
+
else if (!existsSync(hook)) report('problem', hook, `broken symlink → ${readlinkSync(hook)}; commits are unchecked and silent about it`);
|
|
286
|
+
else report('ok', hook, 'hook installed and resolving');
|
|
287
|
+
|
|
288
|
+
const index = join(target, 'adr', 'INDEX.md');
|
|
289
|
+
const docs = loadDocs(target).filter((d) => d.ok);
|
|
290
|
+
if (!existsSync(index)) report('problem', index, 'missing — run /init-method --apply');
|
|
291
|
+
else if (read(index) !== renderIndex(docs)) report('problem', index, 'stale — regenerate it');
|
|
292
|
+
else report('ok', index, 'current');
|
|
293
|
+
|
|
294
|
+
const errors = lintErrors(target);
|
|
295
|
+
if (errors.length > 0) report('problem', target, `${errors.length} lint error(s) — the corpus is red`);
|
|
296
|
+
else report('ok', target, `corpus clean — ${docs.length} document(s)`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {object} options
|
|
301
|
+
* @param {string} options.target repo to install into
|
|
302
|
+
* @param {string} [options.toolkit] this kit's root (overridable for tests)
|
|
303
|
+
* @param {'install'|'check'|'update'} [options.mode]
|
|
304
|
+
* @param {boolean} [options.apply] false = dry run, the default
|
|
305
|
+
* @returns {{actions: Array<{status: string, path: string, message: string}>, problems: number}}
|
|
306
|
+
*/
|
|
307
|
+
export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply = false }) {
|
|
308
|
+
const actions = [];
|
|
309
|
+
const report = (status, path, message) => actions.push({ status, path, message });
|
|
310
|
+
|
|
311
|
+
if (!existsSync(join(target, '.git'))) {
|
|
312
|
+
report('problem', target, 'not a git repository — the hook has nowhere to live. Run `git init` first.');
|
|
313
|
+
return { actions, problems: 1 };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (mode === 'check') {
|
|
317
|
+
check({ target, toolkit, report });
|
|
318
|
+
} else if (mode === 'update') {
|
|
319
|
+
vendor({ target, toolkit, apply, report });
|
|
320
|
+
vendorCommands({ target, toolkit, apply, force: true, report });
|
|
321
|
+
} else {
|
|
322
|
+
scaffold({ target, toolkit, apply, report });
|
|
323
|
+
vendor({ target, toolkit, apply, report });
|
|
324
|
+
vendorCommands({ target, toolkit, apply, force: false, report });
|
|
325
|
+
buildIndex({ target, apply, report });
|
|
326
|
+
installHook({ target, apply, report });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { actions, problems: actions.filter((a) => a.status === 'problem').length };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function main(argv) {
|
|
333
|
+
const flags = new Set(argv.filter((a) => a.startsWith('--')));
|
|
334
|
+
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
335
|
+
const target = positional[0] ?? process.cwd();
|
|
336
|
+
const mode = flags.has('--check') ? 'check' : flags.has('--update') ? 'update' : 'install';
|
|
337
|
+
const apply = flags.has('--apply');
|
|
338
|
+
|
|
339
|
+
const { actions, problems } = initMethod({ target, mode, apply });
|
|
340
|
+
|
|
341
|
+
console.log(`\n${target} — /init-method ${mode}${apply || mode === 'check' ? '' : ' (dry run)'}`);
|
|
342
|
+
for (const a of actions) {
|
|
343
|
+
const where = a.path === target ? target : relative(target, a.path) || a.path;
|
|
344
|
+
console.log(` ${a.status.toUpperCase().padStart(7)} ${where}: ${a.message}`);
|
|
345
|
+
}
|
|
346
|
+
if (!apply && mode === 'install') {
|
|
347
|
+
console.log('\nNothing was written. Re-run with --apply to perform the install.');
|
|
348
|
+
}
|
|
349
|
+
console.log(`\n${problems} problem(s)`);
|
|
350
|
+
return problems > 0 ? 1 : 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// A bin is invoked through a symlink (npm links node_modules/.bin/stele → this file), so
|
|
354
|
+
// process.argv[1] is the LINK path while import.meta.url resolves to the real file — a raw
|
|
355
|
+
// `file://${argv[1]}` compare is false under npx and main() silently never runs, no-opping
|
|
356
|
+
// the whole install (ADR-0015 amendment). Resolve both to real paths before comparing.
|
|
357
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
358
|
+
process.exit(main(process.argv.slice(2)));
|
|
359
|
+
}
|