explainmyrepo 0.1.1
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 +165 -0
- package/assets/design-system/design-system.css +833 -0
- package/assets/design-system/theme-example.css +83 -0
- package/bin/explainmyrepo.mjs +115 -0
- package/kb/ask-kb.mjs +1487 -0
- package/kb/build-kb.mjs +353 -0
- package/kb/corpus-rules.mjs +341 -0
- package/kb/dep-graph.mjs +184 -0
- package/kb/entrypoints.mjs +207 -0
- package/kb/extract-symbols.mjs +322 -0
- package/kb/index-primer.mjs +255 -0
- package/kb/kb-mcp-server.mjs +186 -0
- package/kb/kb.config.mjs +1362 -0
- package/kb/make-dropin.mjs +224 -0
- package/kb/resolve-deps.mjs +126 -0
- package/package.json +52 -0
- package/src/brain.mjs +298 -0
- package/src/build-context.mjs +66 -0
- package/src/claude.mjs +97 -0
- package/src/env.mjs +77 -0
- package/src/orchestrator.mjs +419 -0
- package/src/run-tool.mjs +49 -0
- package/tools/CONTRACT.md +301 -0
- package/tools/assemble-page.mjs +631 -0
- package/tools/build-kb.mjs +159 -0
- package/tools/clone-repo.mjs +161 -0
- package/tools/deploy.mjs +160 -0
- package/tools/generate-image.mjs +280 -0
- package/tools/make-diagrams.mjs +835 -0
- package/tools/make-favicon.mjs +145 -0
- package/tools/make-pack.mjs +295 -0
- package/tools/make-social-card.mjs +198 -0
- package/tools/notify.mjs +327 -0
- package/tools/publish-repo.mjs +156 -0
- package/tools/quality-grade.mjs +746 -0
- package/tools/readme-enhance.mjs +310 -0
- package/tools/repo-seo.mjs +143 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// readme-enhance.mjs — Station 8b, tool #13 of tools/CONTRACT.md (OPTIONAL, off the critical path).
|
|
3
|
+
//
|
|
4
|
+
// JOB (one mechanical thing): OFFER to enhance the SOURCE repo's README — add an architectural
|
|
5
|
+
// explanation + the SHARED Station-4 SVG diagrams (architecture + flow, authored once, reused here)
|
|
6
|
+
// + an explainer badge linking to the live explainer — and deliver it as a PULL REQUEST ONLY on the
|
|
7
|
+
// source repo. NEVER a direct push, NEVER a push to the default branch (INV-16). This wraps the
|
|
8
|
+
// `~/.claude/skills/readme-enhance` conventions (version-headerless, surgical, validate-against-repo)
|
|
9
|
+
// and the `gh` CLI mechanically; it makes no judgment calls.
|
|
10
|
+
//
|
|
11
|
+
// OPTIONAL / OFFERED. The offer is controlled by the brain via the environment: this tool only opens
|
|
12
|
+
// a PR when README_ENHANCE is truthy (1/true/yes/on). Unset/false ⇒ a clean no-op that records
|
|
13
|
+
// readmePr = { prUrl: "declined", svgsShared: [] } and exits 0. (Station 8b cue: "if declined, the
|
|
14
|
+
// station is a clean no-op.")
|
|
15
|
+
//
|
|
16
|
+
// FAIL-LOUD: when ENABLED and a declared input or a git/gh step genuinely fails, this tool exits
|
|
17
|
+
// NON-ZERO with a clear reason (per CONTRACT (b)·6) — it never writes a placeholder PR. Per ADR-0005
|
|
18
|
+
// Station 8b / INV-03 / INV-16 the BRAIN treats that non-zero as a NON-BLOCKING WARNING: a
|
|
19
|
+
// readme-enhance failure is a warning, it never blocks, gates, or sinks the core ship.
|
|
20
|
+
//
|
|
21
|
+
// Uniform invocation: node tools/readme-enhance.mjs <build-dir>
|
|
22
|
+
//
|
|
23
|
+
// Reads (declared inputs only — CONTRACT roster row 13):
|
|
24
|
+
// build.json: repo { owner, name, slug, clonePath, defaultBranch, url },
|
|
25
|
+
// publish.liveUrl,
|
|
26
|
+
// visuals.architectureDiagram { svgPath, altText },
|
|
27
|
+
// visuals.flowDiagram { svgPath, altText }
|
|
28
|
+
// env: README_ENHANCE (opt-in), GitHub token via the ambient `gh` auth / GH_TOKEN.
|
|
29
|
+
// Writes (its own slot + the PR, nothing else):
|
|
30
|
+
// build.json: readmePr { prUrl | "declined", svgsShared[] }
|
|
31
|
+
// the source clone at <build-dir>/repo: docs/explainer/{architecture,flow}.svg + a README block,
|
|
32
|
+
// on a feature branch, pushed (to origin if writable, else a fork) and opened as a PR via gh.
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { execFileSync } from 'node:child_process';
|
|
37
|
+
|
|
38
|
+
const TOOL = 'readme-enhance';
|
|
39
|
+
const BRANCH = 'explainer/readme-enhancement';
|
|
40
|
+
const SVG_DIR = 'docs/explainer';
|
|
41
|
+
const MARK_START = '<!-- explainmyrepo:start -->';
|
|
42
|
+
const MARK_END = '<!-- explainmyrepo:end -->';
|
|
43
|
+
const TRUE_RE = /^(1|true|yes|on)$/i;
|
|
44
|
+
|
|
45
|
+
// stdout carries ONLY the single JSON result object; all diagnostics go to stderr.
|
|
46
|
+
function emit(result) {
|
|
47
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
48
|
+
}
|
|
49
|
+
function log(msg) {
|
|
50
|
+
process.stderr.write(`[${TOOL}] ${msg}\n`);
|
|
51
|
+
}
|
|
52
|
+
function fail(message) {
|
|
53
|
+
log(message);
|
|
54
|
+
emit({ ok: false, outputs: {}, error: message });
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Run a command, capture stdout (trimmed). Throw on non-zero with stderr surfaced.
|
|
59
|
+
function run(cmd, args, opts = {}) {
|
|
60
|
+
try {
|
|
61
|
+
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...opts }).trim();
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const stderr = (err.stderr || '').toString().trim();
|
|
64
|
+
const e = new Error(`${cmd} ${args.join(' ')} failed: ${stderr || err.message}`);
|
|
65
|
+
e.stderr = stderr;
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Resolve a path token (absolute or relative to the build dir) into an absolute path.
|
|
71
|
+
function resolveIn(buildDir, p) {
|
|
72
|
+
return path.isAbsolute(p) ? p : path.resolve(buildDir, p);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const buildDir = process.argv[2];
|
|
76
|
+
if (!buildDir) fail('usage: node tools/readme-enhance.mjs <build-dir>');
|
|
77
|
+
|
|
78
|
+
const buildJsonPath = path.join(buildDir, 'build.json');
|
|
79
|
+
let ctx;
|
|
80
|
+
try {
|
|
81
|
+
ctx = JSON.parse(fs.readFileSync(buildJsonPath, 'utf8'));
|
|
82
|
+
} catch (err) {
|
|
83
|
+
fail(`cannot read ${buildJsonPath}: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── OPTIONAL gate: only act when the brain opted in via the environment ──────────────────────────
|
|
87
|
+
const enabled = TRUE_RE.test(String(process.env.README_ENHANCE || '').trim());
|
|
88
|
+
if (!enabled) {
|
|
89
|
+
ctx.readmePr = { prUrl: 'declined', svgsShared: [] };
|
|
90
|
+
fs.writeFileSync(buildJsonPath, JSON.stringify(ctx, null, 2) + '\n');
|
|
91
|
+
log('README_ENHANCE not set — clean no-op (declined).');
|
|
92
|
+
emit({ ok: true, outputs: { readmePr: ctx.readmePr, declined: true }, error: null });
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── ENABLED: validate the declared inputs (loud on any absence) ──────────────────────────────────
|
|
97
|
+
const repo = ctx.repo;
|
|
98
|
+
if (!repo || !repo.owner || !repo.name || !repo.clonePath) {
|
|
99
|
+
fail('repo slot incomplete — need repo.owner, repo.name, repo.clonePath (run clone-repo first).');
|
|
100
|
+
}
|
|
101
|
+
const liveUrl = ctx.publish?.liveUrl;
|
|
102
|
+
if (!liveUrl) fail('publish.liveUrl is absent — run deploy (Station 8) before the README PR.');
|
|
103
|
+
|
|
104
|
+
const arch = ctx.visuals?.architectureDiagram;
|
|
105
|
+
const flow = ctx.visuals?.flowDiagram;
|
|
106
|
+
if (!arch?.svgPath) fail('visuals.architectureDiagram.svgPath is absent — run make-diagrams (Station 4) first.');
|
|
107
|
+
if (!flow?.svgPath) fail('visuals.flowDiagram.svgPath is absent — run make-diagrams (Station 4) first.');
|
|
108
|
+
|
|
109
|
+
const archSvg = resolveIn(buildDir, arch.svgPath);
|
|
110
|
+
const flowSvg = resolveIn(buildDir, flow.svgPath);
|
|
111
|
+
if (!fs.existsSync(archSvg)) fail(`shared architecture SVG not found on disk: ${archSvg}`);
|
|
112
|
+
if (!fs.existsSync(flowSvg)) fail(`shared flow SVG not found on disk: ${flowSvg}`);
|
|
113
|
+
|
|
114
|
+
const clone = resolveIn(buildDir, repo.clonePath);
|
|
115
|
+
if (!fs.existsSync(clone)) fail(`source clone not found: ${clone} (run clone-repo first).`);
|
|
116
|
+
try {
|
|
117
|
+
run('git', ['-C', clone, 'rev-parse', '--is-inside-work-tree']);
|
|
118
|
+
} catch {
|
|
119
|
+
fail(`not a git work tree: ${clone}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const baseBranch =
|
|
123
|
+
repo.defaultBranch ||
|
|
124
|
+
(() => {
|
|
125
|
+
try {
|
|
126
|
+
return run('git', ['-C', clone, 'symbolic-ref', '--short', 'HEAD']);
|
|
127
|
+
} catch {
|
|
128
|
+
return 'main';
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
|
|
132
|
+
// ── Build the surgical README enhancement (badge + shared SVGs + architectural explanation) ───────
|
|
133
|
+
const repoName = repo.name;
|
|
134
|
+
const archAlt = (arch.altText || `Architecture diagram for ${repoName}.`).trim();
|
|
135
|
+
const flowAlt = (flow.altText || `Process / data-flow diagram for ${repoName}.`).trim();
|
|
136
|
+
const archRel = `${SVG_DIR}/architecture.svg`;
|
|
137
|
+
const flowRel = `${SVG_DIR}/flow.svg`;
|
|
138
|
+
|
|
139
|
+
const block = [
|
|
140
|
+
MARK_START,
|
|
141
|
+
'## Explainer',
|
|
142
|
+
'',
|
|
143
|
+
`[](${liveUrl})`,
|
|
144
|
+
'',
|
|
145
|
+
`A visual, newcomer-friendly explainer for **${repoName}** is live: ${liveUrl}`,
|
|
146
|
+
'',
|
|
147
|
+
'### Architecture',
|
|
148
|
+
'',
|
|
149
|
+
archAlt,
|
|
150
|
+
'',
|
|
151
|
+
`![${archAlt.replace(/[\r\n]+/g, ' ')}](${archRel})`,
|
|
152
|
+
'',
|
|
153
|
+
'### How it works',
|
|
154
|
+
'',
|
|
155
|
+
flowAlt,
|
|
156
|
+
'',
|
|
157
|
+
`![${flowAlt.replace(/[\r\n]+/g, ' ')}](${flowRel})`,
|
|
158
|
+
MARK_END,
|
|
159
|
+
'',
|
|
160
|
+
].join('\n');
|
|
161
|
+
|
|
162
|
+
// Pick the existing README (case-insensitive) or default to README.md.
|
|
163
|
+
function findReadme(dir) {
|
|
164
|
+
const hit = fs.readdirSync(dir).find((f) => /^readme(\.md|\.markdown)?$/i.test(f));
|
|
165
|
+
return hit || 'README.md';
|
|
166
|
+
}
|
|
167
|
+
const readmeName = findReadme(clone);
|
|
168
|
+
const readmePath = path.join(clone, readmeName);
|
|
169
|
+
|
|
170
|
+
// ALWAYS read before editing. Insert-or-replace our delimited block (idempotent on re-run).
|
|
171
|
+
let readme = '';
|
|
172
|
+
if (fs.existsSync(readmePath)) {
|
|
173
|
+
readme = fs.readFileSync(readmePath, 'utf8');
|
|
174
|
+
}
|
|
175
|
+
let nextReadme;
|
|
176
|
+
if (readme.includes(MARK_START) && readme.includes(MARK_END)) {
|
|
177
|
+
nextReadme = readme.replace(new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}\\n?`), block);
|
|
178
|
+
} else if (readme.trim().length === 0) {
|
|
179
|
+
nextReadme = `# ${repoName}\n\n${block}`;
|
|
180
|
+
} else {
|
|
181
|
+
nextReadme = `${readme.replace(/\s*$/, '')}\n\n${block}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Stage the change on a feature branch in the clone (NEVER the default branch) ──────────────────
|
|
185
|
+
let prUrl;
|
|
186
|
+
try {
|
|
187
|
+
run('git', ['-C', clone, 'checkout', '-B', BRANCH]);
|
|
188
|
+
|
|
189
|
+
const svgAbsDir = path.join(clone, SVG_DIR);
|
|
190
|
+
fs.mkdirSync(svgAbsDir, { recursive: true });
|
|
191
|
+
fs.copyFileSync(archSvg, path.join(svgAbsDir, 'architecture.svg'));
|
|
192
|
+
fs.copyFileSync(flowSvg, path.join(svgAbsDir, 'flow.svg'));
|
|
193
|
+
fs.writeFileSync(readmePath, nextReadme);
|
|
194
|
+
|
|
195
|
+
run('git', ['-C', clone, 'add', '--', readmeName, SVG_DIR]);
|
|
196
|
+
|
|
197
|
+
// Commit with an explicit identity (the clone may carry none). No Co-Authored-By trailer.
|
|
198
|
+
try {
|
|
199
|
+
run('git', [
|
|
200
|
+
'-C', clone,
|
|
201
|
+
'-c', 'user.name=explainmyrepo',
|
|
202
|
+
'-c', 'user.email=explainmyrepo@users.noreply.github.com',
|
|
203
|
+
'commit', '-m', 'docs: add explainmyrepo architecture explainer + shared diagrams',
|
|
204
|
+
]);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (!/nothing to commit/i.test(err.stderr || err.message)) throw err;
|
|
207
|
+
log('no README changes to commit (already enhanced) — proceeding to PR.');
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
fail(`failed to stage the README enhancement: ${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Push (origin if writable, else a fork) and open the PR via gh — never a direct push to base ──
|
|
214
|
+
let headRef = BRANCH; // owner:branch form filled in for the fork path
|
|
215
|
+
try {
|
|
216
|
+
// Idempotency: if a PR already exists for this head, reuse it.
|
|
217
|
+
const existing = run('gh', [
|
|
218
|
+
'pr', 'list', '--repo', `${repo.owner}/${repo.name}`,
|
|
219
|
+
'--head', BRANCH, '--state', 'open', '--json', 'url', '--jq', '.[0].url // ""',
|
|
220
|
+
]);
|
|
221
|
+
if (existing) {
|
|
222
|
+
log(`a PR already exists for ${BRANCH} — reusing it.`);
|
|
223
|
+
prUrl = existing;
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
/* gh pr list is best-effort detection; fall through to create */
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!prUrl) {
|
|
230
|
+
let pushedToOrigin = false;
|
|
231
|
+
try {
|
|
232
|
+
run('git', ['-C', clone, 'push', '--force-with-lease', '-u', 'origin', BRANCH]);
|
|
233
|
+
pushedToOrigin = true;
|
|
234
|
+
} catch (err) {
|
|
235
|
+
log(`push to origin failed (likely no write access): ${err.stderr || err.message} — trying a fork.`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!pushedToOrigin) {
|
|
239
|
+
// Fork under our account, push the branch there, open a cross-repo PR.
|
|
240
|
+
let login;
|
|
241
|
+
try {
|
|
242
|
+
run('gh', ['repo', 'fork', `${repo.owner}/${repo.name}`, '--clone=false']);
|
|
243
|
+
login = run('gh', ['api', 'user', '--jq', '.login']);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
fail(`could not fork ${repo.owner}/${repo.name} for the PR: ${err.message}`);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
run('git', ['-C', clone, 'remote', 'remove', 'explainer-fork']);
|
|
249
|
+
} catch {
|
|
250
|
+
/* no pre-existing fork remote — fine */
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
run('git', ['-C', clone, 'remote', 'add', 'explainer-fork', `https://github.com/${login}/${repo.name}.git`]);
|
|
254
|
+
run('git', ['-C', clone, 'push', '--force-with-lease', '-u', 'explainer-fork', BRANCH]);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
fail(`could not push the branch to the fork ${login}/${repo.name}: ${err.message}`);
|
|
257
|
+
}
|
|
258
|
+
headRef = `${login}:${BRANCH}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const title = `docs: add a visual explainer for ${repoName}`;
|
|
262
|
+
const body = [
|
|
263
|
+
`This optional PR adds a short **Explainer** section to the README:`,
|
|
264
|
+
'',
|
|
265
|
+
`- an explainer badge linking to the live explainer (${liveUrl})`,
|
|
266
|
+
'- an **Architecture** diagram and a **How it works** flow diagram (shared SVGs, authored once)',
|
|
267
|
+
'- a brief architectural explanation alongside each diagram',
|
|
268
|
+
'',
|
|
269
|
+
'It touches only the README and `docs/explainer/`. Merge it or close it — no pressure.',
|
|
270
|
+
'',
|
|
271
|
+
'🤖 Generated with [Claude Code](https://claude.com/claude-code)',
|
|
272
|
+
].join('\n');
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
prUrl = run('gh', [
|
|
276
|
+
'pr', 'create',
|
|
277
|
+
'--repo', `${repo.owner}/${repo.name}`,
|
|
278
|
+
'--base', baseBranch,
|
|
279
|
+
'--head', headRef,
|
|
280
|
+
'--title', title,
|
|
281
|
+
'--body', body,
|
|
282
|
+
]);
|
|
283
|
+
// gh prints the PR URL on the last line of stdout.
|
|
284
|
+
prUrl = prUrl.split('\n').map((l) => l.trim()).filter(Boolean).pop();
|
|
285
|
+
} catch (err) {
|
|
286
|
+
fail(`gh pr create failed: ${err.message}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!prUrl || !/^https?:\/\//.test(prUrl)) {
|
|
291
|
+
fail(`could not determine the opened PR URL (got: ${JSON.stringify(prUrl)}).`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Merge ONLY the readmePr slot back; leave every other slot untouched ───────────────────────────
|
|
295
|
+
ctx.readmePr = { prUrl, svgsShared: ['architecture.svg', 'flow.svg'] };
|
|
296
|
+
fs.writeFileSync(buildJsonPath, JSON.stringify(ctx, null, 2) + '\n');
|
|
297
|
+
|
|
298
|
+
log(`opened README PR: ${prUrl}`);
|
|
299
|
+
emit({
|
|
300
|
+
ok: true,
|
|
301
|
+
outputs: {
|
|
302
|
+
readmePr: ctx.readmePr,
|
|
303
|
+
prUrl,
|
|
304
|
+
head: headRef,
|
|
305
|
+
base: baseBranch,
|
|
306
|
+
svgsShared: ctx.readmePr.svgsShared,
|
|
307
|
+
},
|
|
308
|
+
error: null,
|
|
309
|
+
});
|
|
310
|
+
process.exit(0);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// repo-seo.mjs — Station 8 tool #12: make the explainer repo discoverable + suggest source-repo SEO.
|
|
3
|
+
//
|
|
4
|
+
// CONTRACT (tools/CONTRACT.md): node tools/repo-seo.mjs <build-dir>
|
|
5
|
+
// Reads (declared inputs): publish.explainerRepoUrl, concept, understanding.summary (+ GitHub token)
|
|
6
|
+
// Writes (own slot only): publish.repoTopics, publish.repoDescription, publish.sourceRepoSeoSuggested
|
|
7
|
+
// stdout = ONE JSON result object; diagnostics → stderr; exit 0 iff ok:true, else non-zero.
|
|
8
|
+
//
|
|
9
|
+
// Sets GitHub TOPICS + a strong description on the EXPLAINER repo via the GitHub API (GitHub is the
|
|
10
|
+
// new AI-world social media). Topics/description are derived MECHANICALLY from the brain-authored
|
|
11
|
+
// concept + understanding.summary — the tool never invents judgement. For the SOURCE repo it only
|
|
12
|
+
// EMITS suggestions (offered, never set — INV-16).
|
|
13
|
+
//
|
|
14
|
+
// FAIL LOUD: a missing input, a failed API write, or a write that does not persist (verified by a
|
|
15
|
+
// read-back) is a non-zero exit with a clear message — never a silent green.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
const STOP = new Set(('the a an and or of to in for with on is are be this that it its as by from into how what why your you our we their they them use uses using via not no yes can will built make makes new one two repo repository project code library tool app site web page based simple easy fast just like more most all any each other than then over under between'.split(' ')));
|
|
22
|
+
|
|
23
|
+
function readContext(buildDir) {
|
|
24
|
+
const p = path.join(buildDir, 'build.json');
|
|
25
|
+
let raw;
|
|
26
|
+
try { raw = fs.readFileSync(p, 'utf8'); }
|
|
27
|
+
catch { throw new Error(`build.json not found at ${p} (run earlier stations first)`); }
|
|
28
|
+
try { return JSON.parse(raw); }
|
|
29
|
+
catch (e) { throw new Error(`build.json is not valid JSON: ${e.message}`); }
|
|
30
|
+
}
|
|
31
|
+
function mergeSlot(buildDir, slot, partial) {
|
|
32
|
+
const p = path.join(buildDir, 'build.json');
|
|
33
|
+
const obj = JSON.parse(fs.readFileSync(p, 'utf8')); // re-read fresh, merge ONLY this slot's keys
|
|
34
|
+
obj[slot] = { ...(obj[slot] || {}), ...partial };
|
|
35
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n');
|
|
36
|
+
}
|
|
37
|
+
const errText = (e) => (e.stderr ? e.stderr.toString() : '') || e.message || String(e);
|
|
38
|
+
|
|
39
|
+
function requireGh() {
|
|
40
|
+
try { execFileSync('gh', ['--version'], { stdio: ['ignore', 'ignore', 'ignore'] }); }
|
|
41
|
+
catch { throw new Error("GitHub CLI 'gh' not found in PATH (required to set explainer-repo SEO)"); }
|
|
42
|
+
}
|
|
43
|
+
function parseRepoUrl(url) {
|
|
44
|
+
const m = String(url).match(/github\.com[/:]([^/]+)\/([^/.\s]+?)(?:\.git)?\/?$/);
|
|
45
|
+
if (!m) throw new Error(`cannot parse owner/repo from publish.explainerRepoUrl: ${url}`);
|
|
46
|
+
return { owner: m[1], repo: m[2] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- mechanical topic / description derivation ----
|
|
50
|
+
const toTopic = (w) => w.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 50);
|
|
51
|
+
function keywords(text, limit) {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const raw of String(text || '').split(/[^A-Za-z0-9]+/)) {
|
|
55
|
+
const t = toTopic(raw);
|
|
56
|
+
if (t.length < 3 || STOP.has(t) || /^\d+$/.test(t) || seen.has(t)) continue;
|
|
57
|
+
seen.add(t); out.push(t);
|
|
58
|
+
if (out.length >= limit) break;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function buildTopics(concept, summary, baseSlug, { source }) {
|
|
63
|
+
const base = source ? [] : ['explainer', 'documentation', 'knowledge-base'];
|
|
64
|
+
const slug = source ? [] : keywords(baseSlug, 2);
|
|
65
|
+
const metaphor = keywords(concept?.metaphor, 2);
|
|
66
|
+
const tagline = keywords(concept?.tagline, 4);
|
|
67
|
+
const fromSummary = keywords(summary, 10);
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const t of [...base, ...slug, ...metaphor, ...tagline, ...fromSummary]) {
|
|
71
|
+
if (t && !seen.has(t)) { seen.add(t); out.push(t); }
|
|
72
|
+
if (out.length >= 20) break; // GitHub caps topics at 20
|
|
73
|
+
}
|
|
74
|
+
if (!out.length) throw new Error('could not derive any valid topics from concept + understanding.summary');
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
const clamp = (s, n) => { const t = String(s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };
|
|
78
|
+
function buildDescription(concept, summary) {
|
|
79
|
+
const tag = concept?.tagline ? `${String(concept.tagline).trim()} — ` : '';
|
|
80
|
+
const d = clamp(`${tag}${summary}`, 350); // GitHub repo description practical cap
|
|
81
|
+
if (!d) throw new Error('could not derive a repo description from concept.tagline + understanding.summary');
|
|
82
|
+
return d;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- GitHub API via gh ----
|
|
86
|
+
function ghSetDescription(owner, repo, description) {
|
|
87
|
+
try { execFileSync('gh', ['api', '-X', 'PATCH', `repos/${owner}/${repo}`, '-f', `description=${description}`], { stdio: ['ignore', 'ignore', 'pipe'] }); }
|
|
88
|
+
catch (e) { throw new Error(`set description on ${owner}/${repo} failed: ${errText(e).slice(0, 200)}`); }
|
|
89
|
+
}
|
|
90
|
+
function ghSetTopics(owner, repo, topics) {
|
|
91
|
+
try { execFileSync('gh', ['api', '-X', 'PUT', `repos/${owner}/${repo}/topics`, '--input', '-'], { input: JSON.stringify({ names: topics }), stdio: ['pipe', 'ignore', 'pipe'] }); }
|
|
92
|
+
catch (e) { throw new Error(`set topics on ${owner}/${repo} failed: ${errText(e).slice(0, 200)}`); }
|
|
93
|
+
}
|
|
94
|
+
function ghGetTopics(owner, repo) {
|
|
95
|
+
try { return JSON.parse(execFileSync('gh', ['api', `repos/${owner}/${repo}/topics`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).names || []; }
|
|
96
|
+
catch (e) { throw new Error(`read-back of topics on ${owner}/${repo} failed: ${errText(e).slice(0, 200)}`); }
|
|
97
|
+
}
|
|
98
|
+
function ghGetDescription(owner, repo) {
|
|
99
|
+
try { return execFileSync('gh', ['api', `repos/${owner}/${repo}`, '--jq', '.description // ""'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
|
|
100
|
+
catch (e) { throw new Error(`read-back of description on ${owner}/${repo} failed: ${errText(e).slice(0, 200)}`); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function main() {
|
|
104
|
+
const buildDir = process.argv[2];
|
|
105
|
+
if (!buildDir) throw new Error('usage: node tools/repo-seo.mjs <build-dir>');
|
|
106
|
+
|
|
107
|
+
const bc = readContext(buildDir);
|
|
108
|
+
const explainerRepoUrl = bc.publish?.explainerRepoUrl;
|
|
109
|
+
if (!explainerRepoUrl) throw new Error('publish.explainerRepoUrl missing in build.json (run publish-repo first)');
|
|
110
|
+
const concept = bc.concept || {};
|
|
111
|
+
const summary = bc.understanding?.summary;
|
|
112
|
+
if (!summary) throw new Error('understanding.summary missing in build.json (run build-kb first)');
|
|
113
|
+
|
|
114
|
+
requireGh();
|
|
115
|
+
if (!(process.env.GITHUB_TOKEN || process.env.GH_TOKEN)) throw new Error('GITHUB_TOKEN (or GH_TOKEN) not set in environment (required to set explainer-repo SEO)');
|
|
116
|
+
|
|
117
|
+
const { owner, repo } = parseRepoUrl(explainerRepoUrl);
|
|
118
|
+
const topics = buildTopics(concept, summary, repo.replace(/-explainer$/, ''), { source: false });
|
|
119
|
+
const description = buildDescription(concept, summary);
|
|
120
|
+
|
|
121
|
+
ghSetDescription(owner, repo, description);
|
|
122
|
+
ghSetTopics(owner, repo, topics);
|
|
123
|
+
console.error(`[repo-seo] set ${topics.length} topics + description on ${owner}/${repo}`);
|
|
124
|
+
|
|
125
|
+
// verify the writes actually persisted (fail loud, never a silent green)
|
|
126
|
+
const repoTopics = ghGetTopics(owner, repo);
|
|
127
|
+
const repoDescription = ghGetDescription(owner, repo);
|
|
128
|
+
if (!repoTopics.length) throw new Error(`topics did not persist on ${owner}/${repo} (GitHub API read-back returned none)`);
|
|
129
|
+
if (!repoDescription) throw new Error(`description did not persist on ${owner}/${repo} (GitHub API read-back returned empty)`);
|
|
130
|
+
|
|
131
|
+
// SUGGESTED only for the SOURCE repo (offered, never set — INV-16)
|
|
132
|
+
const sourceRepoSeoSuggested = {
|
|
133
|
+
topics: buildTopics(concept, summary, repo.replace(/-explainer$/, ''), { source: true }),
|
|
134
|
+
description: clamp(summary, 350),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
mergeSlot(buildDir, 'publish', { repoTopics, repoDescription, sourceRepoSeoSuggested });
|
|
138
|
+
return { repoTopics, repoDescription, sourceRepoSeoSuggested, slot: 'publish' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main()
|
|
142
|
+
.then((outputs) => { process.stdout.write(JSON.stringify({ ok: true, outputs, error: null }) + '\n'); process.exit(0); })
|
|
143
|
+
.catch((e) => { process.stdout.write(JSON.stringify({ ok: false, outputs: {}, error: e.message || String(e) }) + '\n'); process.exit(1); });
|