@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/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 the set of
70
- // entry filenames that already exist in the target repo (existingFiles), so
71
- // this function stays testable against throwaway fixture repos.
72
- export function scanProject(project, { branch = 'main', fetch = true, existingFiles = new Set() } = {}) {
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
- if (existingFiles.has(`${version}.md`)) {
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
- // Which entry files already exist in the target repo for one project — a
189
- // single `gh api` directory listing (vs. the old one-probe-per-tag pattern).
190
- // Returns { files: Set, status: 'ok' | 'empty' | 'failed' }: a 404 means the
191
- // project has no entries yet; any other failure is surfaced so the caller
192
- // knows the entry-exists filter may be incomplete (publish-entry still refuses
193
- // overwrites against the fresh clone, so a stale scan cannot clobber anything).
194
- export function fetchExistingEntries(targetRepo, branch, projectKey) {
195
- const r = spawnArgs('gh', ['api', `repos/${targetRepo}/contents/${projectKey}?ref=${branch}`, '--jq', '.[].name']);
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 { files: new Set(), status: 'empty' };
252
+ return { ...emptyExisting(), status: 'empty' };
201
253
  }
202
- return { files: new Set(), status: 'failed' };
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
- const existing = getExisting(config.targetRepo, branch, project.key);
222
- const scanned = scanProject(project, { branch, fetch, existingFiles: existing.files });
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.9.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",
@@ -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
  }