@natjswenson/devlog 0.9.0 → 0.11.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/SKILL.md +151 -82
- package/bin/devlog.js +160 -29
- package/examples/react/useDevLogEntries.js +2 -0
- package/image-style/icons.md +3 -2
- package/image-style/style-guide.example.md +165 -28
- package/lib/assemble_post.mjs +71 -0
- package/lib/config_ops.mjs +1 -0
- package/lib/core.mjs +12 -0
- package/lib/cover_gen.mjs +6 -1
- package/lib/lint_post.mjs +47 -1
- package/lib/migrate_entry_numbers.mjs +116 -0
- package/lib/publish_entry.mjs +255 -14
- package/lib/scan.mjs +101 -18
- package/package.json +1 -1
- package/skill-invariants.json +21 -1
package/lib/scan.mjs
CHANGED
|
@@ -66,10 +66,11 @@ function git(projectPath, args) {
|
|
|
66
66
|
return execArgs('git', ['-C', projectPath, ...args]);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// Scan one project's local clone. Pure git — the caller supplies
|
|
70
|
-
//
|
|
71
|
-
// this function stays testable against
|
|
72
|
-
|
|
69
|
+
// Scan one project's local clone. Pure git — the caller supplies what already
|
|
70
|
+
// exists in the target repo (`existing`: entry filenames, live manifest
|
|
71
|
+
// versions, and tombstoned versions), so this function stays testable against
|
|
72
|
+
// throwaway fixture repos.
|
|
73
|
+
export function scanProject(project, { branch = 'main', fetch = true, existing = emptyExisting() } = {}) {
|
|
73
74
|
const out = {
|
|
74
75
|
key: project.key,
|
|
75
76
|
label: project.label || project.key,
|
|
@@ -119,7 +120,14 @@ export function scanProject(project, { branch = 'main', fetch = true, existingFi
|
|
|
119
120
|
|
|
120
121
|
for (let i = 0; i < releases.length; i++) {
|
|
121
122
|
const { tag, version } = releases[i];
|
|
122
|
-
|
|
123
|
+
// Tombstone check comes first: a tombstoned row also carries a `file`
|
|
124
|
+
// field, so the entry-exists check below would otherwise mask the more
|
|
125
|
+
// specific reason.
|
|
126
|
+
if (existing.removedVersions.has(version)) {
|
|
127
|
+
out.skippedTags.push({ tag, reason: 'entry-tombstoned' });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (existing.versions.has(version) || existing.files.has(`${version}.md`)) {
|
|
123
131
|
out.skippedTags.push({ tag, reason: 'entry-exists' });
|
|
124
132
|
continue;
|
|
125
133
|
}
|
|
@@ -185,21 +193,65 @@ function splitLogLine(line) {
|
|
|
185
193
|
return [hash, subject, date];
|
|
186
194
|
}
|
|
187
195
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
+
export function emptyExisting() {
|
|
197
|
+
return { files: new Set(), versions: new Set(), removedVersions: new Set(), entries: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// What already exists in the target repo for one project. Entry identity is
|
|
201
|
+
// project+version in the MANIFEST, not a filename: manifest rows survive
|
|
202
|
+
// editorial file moves/consolidations, and a tombstoned row (`removed: true`)
|
|
203
|
+
// keeps suppressing generation even after its .md is gone — the failure class
|
|
204
|
+
// that re-armed three deleted entries in the first six runs. Fetches the
|
|
205
|
+
// project's manifest.json (one `gh api` call); falls back to a directory
|
|
206
|
+
// listing for legacy dirs with entries but no manifest yet.
|
|
207
|
+
// Returns { files: Set, versions: Set, removedVersions: Set,
|
|
208
|
+
// entries: [{version, title, tags}] (live rows only),
|
|
209
|
+
// status: 'ok' | 'empty' | 'failed' }: 'empty' means the project has
|
|
210
|
+
// no entries yet; 'failed' is surfaced so the caller knows the entry-exists
|
|
211
|
+
// filter may be incomplete (publish-entry still refuses overwrites against the
|
|
212
|
+
// fresh clone, so a stale scan cannot clobber anything).
|
|
213
|
+
export function fetchExistingEntries(targetRepo, branch, projectKey, targetDir = '') {
|
|
214
|
+
const contentPath = targetDir ? `${targetDir}/${projectKey}` : projectKey;
|
|
215
|
+
const m = spawnArgs('gh', ['api', `repos/${targetRepo}/contents/${contentPath}/manifest.json?ref=${branch}`, '--jq', '.content']);
|
|
216
|
+
if (m.status === 0) {
|
|
217
|
+
let manifest = null;
|
|
218
|
+
try {
|
|
219
|
+
manifest = JSON.parse(Buffer.from(m.stdout.replace(/\s/g, ''), 'base64').toString('utf8'));
|
|
220
|
+
} catch {
|
|
221
|
+
// Malformed manifest content — fall through to the directory listing.
|
|
222
|
+
}
|
|
223
|
+
if (manifest && Array.isArray(manifest.entries)) {
|
|
224
|
+
const out = { ...emptyExisting(), status: 'ok' };
|
|
225
|
+
for (const e of manifest.entries) {
|
|
226
|
+
if (!e) continue;
|
|
227
|
+
if (typeof e.file === 'string') out.files.add(e.file);
|
|
228
|
+
if (typeof e.version !== 'string' || e.version === '') continue;
|
|
229
|
+
if (e.removed) {
|
|
230
|
+
out.removedVersions.add(e.version);
|
|
231
|
+
} else {
|
|
232
|
+
out.versions.add(e.version);
|
|
233
|
+
out.entries.push({
|
|
234
|
+
version: e.version,
|
|
235
|
+
title: typeof e.title === 'string' ? e.title : null,
|
|
236
|
+
tags: Array.isArray(e.tags) ? e.tags : [],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
} else if (!/HTTP 404|Not Found/i.test(m.stderr)) {
|
|
243
|
+
return { ...emptyExisting(), status: 'failed' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// No manifest (or unparseable): legacy directory listing.
|
|
247
|
+
const r = spawnArgs('gh', ['api', `repos/${targetRepo}/contents/${contentPath}?ref=${branch}`, '--jq', '.[].name']);
|
|
196
248
|
if (r.status === 0) {
|
|
197
|
-
return { files: new Set(r.stdout.split('\n').filter(Boolean)), status: 'ok' };
|
|
249
|
+
return { ...emptyExisting(), files: new Set(r.stdout.split('\n').filter(Boolean)), status: 'ok' };
|
|
198
250
|
}
|
|
199
251
|
if (/HTTP 404|Not Found/i.test(r.stderr)) {
|
|
200
|
-
return {
|
|
252
|
+
return { ...emptyExisting(), status: 'empty' };
|
|
201
253
|
}
|
|
202
|
-
return {
|
|
254
|
+
return { ...emptyExisting(), status: 'failed' };
|
|
203
255
|
}
|
|
204
256
|
|
|
205
257
|
// Full scan across the configured projects. `getExisting` is injectable for
|
|
@@ -218,14 +270,23 @@ export function scanAll(config, { projectKey = null, fetch = true, getExisting =
|
|
|
218
270
|
}
|
|
219
271
|
|
|
220
272
|
const results = projects.map((project) => {
|
|
221
|
-
|
|
222
|
-
|
|
273
|
+
// Normalized so an injected getExisting returning a partial shape (e.g. a
|
|
274
|
+
// legacy { files, status } double) can't crash the tombstone checks.
|
|
275
|
+
const existing = { ...emptyExisting(), status: 'ok', ...getExisting(config.targetRepo, branch, project.key, config.targetDir || '') };
|
|
276
|
+
const scanned = scanProject(project, { branch, fetch, existing });
|
|
223
277
|
scanned.existenceCheck = existing.status;
|
|
278
|
+
// Live catalog rows for this project — the skill's topic-dedup input
|
|
279
|
+
// ("don't re-teach a guide the catalog already covers"), free with the
|
|
280
|
+
// manifest fetch above.
|
|
281
|
+
scanned.publishedEntries = existing.entries;
|
|
224
282
|
return scanned;
|
|
225
283
|
});
|
|
226
284
|
|
|
227
285
|
return {
|
|
228
286
|
targetRepo: config.targetRepo,
|
|
287
|
+
// Subdirectory of targetRepo holding the content tree ('' = repo root) — the
|
|
288
|
+
// skill appends it to the publish clone path (`--clone <clone>/<targetDir>`).
|
|
289
|
+
targetDir: config.targetDir || '',
|
|
229
290
|
branch,
|
|
230
291
|
deepDive: resolveDeepDive(config),
|
|
231
292
|
voicePath: config.voicePath || null,
|
|
@@ -233,3 +294,25 @@ export function scanAll(config, { projectKey = null, fetch = true, getExisting =
|
|
|
233
294
|
totalNewReleases: results.reduce((n, p) => n + p.newReleases.length, 0),
|
|
234
295
|
};
|
|
235
296
|
}
|
|
297
|
+
|
|
298
|
+
// Compact plan-table view of a scanAll result: per release, drop the commit
|
|
299
|
+
// list and diffstat (the bulky parts) for a commitCount; collapse skippedTags
|
|
300
|
+
// to per-reason counts. publishedEntries stays — it's small and the skill's
|
|
301
|
+
// topic-dedup input. Full detail remains one `scan --project <key>` away.
|
|
302
|
+
export function summarizeScan(result) {
|
|
303
|
+
if (result.error) return result;
|
|
304
|
+
return {
|
|
305
|
+
...result,
|
|
306
|
+
projects: result.projects.map((p) => ({
|
|
307
|
+
...p,
|
|
308
|
+
newReleases: p.newReleases.map(({ commits, diffstat, ...release }) => ({
|
|
309
|
+
...release,
|
|
310
|
+
commitCount: commits.length,
|
|
311
|
+
})),
|
|
312
|
+
skippedTags: p.skippedTags.reduce((acc, { reason }) => {
|
|
313
|
+
acc[reason] = (acc[reason] || 0) + 1;
|
|
314
|
+
return acc;
|
|
315
|
+
}, {}),
|
|
316
|
+
})),
|
|
317
|
+
};
|
|
318
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Release dev log generator \u2014 Claude Code skill + preview app for publishing version-release dev logs, written in your voice, to your site",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nate Swenson",
|
package/skill-invariants.json
CHANGED
|
@@ -81,6 +81,26 @@
|
|
|
81
81
|
"pattern": "this interactive review IS the quality gate for the cover",
|
|
82
82
|
"rationale": "The rendered cover must be shown to the user before push, the same way Step 4 gates the prose — losing this line reopens publishing an unreviewed cover."
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
"id": "ground-truth-gate",
|
|
86
|
+
"pattern": "verify each one with a git command run NOW",
|
|
87
|
+
"rationale": "The 6-run audit found a post that published a provably false premise about the author's own repo (resume/v1.0.1: 'two of four packages never got tags' — all four existed). Every repo-fact claim must be re-verified against git in-session before publish; losing this line reopens the fabricated-premise path."
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"id": "no-unrun-output",
|
|
91
|
+
"pattern": "never label output as real[\\s\\S]{0,120}unless the command that produced it ran in\\s+this session",
|
|
92
|
+
"rationale": "Two audited posts claimed 'real output' over commands that never ran (v0.6.0's phantom fixtures, v0.10.0's internally-impossible scan table). Output honesty must be a hard rule, not a stylistic preference."
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": "tombstone-never-republish",
|
|
96
|
+
"pattern": "editorially retired[\\s\\S]{0,80}skip it silently, never regenerate",
|
|
97
|
+
"rationale": "Deleted/moved entries re-armed generation three times in the first six runs (ghostwriter v0.8.1 twice, market-research v0.1.0). The tombstoned state must always mean 'never regenerate'."
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"id": "one-commit-one-changelog",
|
|
101
|
+
"pattern": "refuses a draft whose Changelog repeats a commit",
|
|
102
|
+
"rationale": "Monorepo twin releases shipped identical Changelogs (resume v1.0.1 + ghostwriter v0.8.1); a commit belongs to exactly one post's Changelog and publish-entry enforces it."
|
|
103
|
+
},
|
|
84
104
|
{
|
|
85
105
|
"id": "cover-custom-illustration",
|
|
86
106
|
"pattern": "cover that just re-renders the title in large text is a failure",
|
|
@@ -95,5 +115,5 @@
|
|
|
95
115
|
"rationale": "The catalog-icon/hero-zone overlap check must stay wired into renderCoverImage() — losing it silently reopens the gap where a catalog icon (or two, connected by a line) can stand in for the required bespoke hero illustration."
|
|
96
116
|
}
|
|
97
117
|
],
|
|
98
|
-
"cli_commands_referenced": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init", "cover-context", "render-cover"]
|
|
118
|
+
"cli_commands_referenced": ["scan", "lint-post", "publish-entry", "add-project", "remove-project", "set", "config", "init", "cover-context", "render-cover", "tombstone", "sync-entry", "assemble-post"]
|
|
99
119
|
}
|