@natjswenson/devlog 0.4.2 → 0.5.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/SKILL.md +223 -405
- package/bin/devlog.js +319 -203
- package/config.example.json +4 -0
- package/evals/budget.mjs +60 -0
- package/evals/fixtures/bad-post.md +33 -0
- package/evals/fixtures/good-post.md +118 -0
- package/evals/fixtures/irreproducible-post.md +71 -0
- package/evals/judge_post.mjs +113 -0
- package/evals/run_eval.mjs +95 -0
- package/lib/config_ops.mjs +58 -0
- package/lib/core.mjs +206 -0
- package/lib/lint_post.mjs +152 -0
- package/lib/publish_entry.mjs +71 -0
- package/lib/scan.mjs +229 -0
- package/package.json +4 -1
- package/skill-invariants.json +66 -0
package/lib/core.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Shared config validation, IO, and process helpers for the devlog CLI.
|
|
2
|
+
// Single source of truth: bin/devlog.js re-exports the validators from here so
|
|
3
|
+
// external importers (tests, SKILL.md guidance) keep one canonical definition.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
// SHELL_QUOTE_BREAK matches characters that can break out of a single-quoted
|
|
10
|
+
// shell string OR are dangerous if quoting is omitted. The skill instructs the
|
|
11
|
+
// LLM to single-quote every interpolated value; rejecting these chars upstream
|
|
12
|
+
// guarantees that single-quoting is sufficient. Whitespace, dots, hyphens,
|
|
13
|
+
// equals, and similar are NOT rejected — they're literal inside '...' and are
|
|
14
|
+
// legitimate in human-readable fields like names and paths.
|
|
15
|
+
//
|
|
16
|
+
// For strict-token fields (project keys, repo names, branch names), separate
|
|
17
|
+
// allowlist regexes apply additional structural constraints.
|
|
18
|
+
export const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
|
|
19
|
+
export const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
|
|
20
|
+
export const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
21
|
+
export const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
|
|
22
|
+
export const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
23
|
+
export const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
24
|
+
// Repo-relative subdir used to scope `git log` to one skill in a monorepo.
|
|
25
|
+
// Same shape as a branch: no leading dash/slash, no shell metacharacters.
|
|
26
|
+
export const RE_PATH_FILTER = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
27
|
+
// Git tag prefix that marks a project's releases (e.g. `v` or `devlog-v`).
|
|
28
|
+
// Interpolated into `git tag --list '<tagPrefix>*'`; same safety as a path filter.
|
|
29
|
+
export const RE_TAG_PREFIX = /^[a-z0-9][a-z0-9._/-]*$/i;
|
|
30
|
+
export const FORBIDDEN_BRANCH_PARTS = /(^|\/)\.\.($|\/)/; // reject `..` as a path component
|
|
31
|
+
|
|
32
|
+
// A final-release version label: `v` + digits and dots only. Prereleases
|
|
33
|
+
// (v1.0.0-rc.1) and build metadata (v1.0.0+build) are excluded by design —
|
|
34
|
+
// they must never get an entry or serve as a range base.
|
|
35
|
+
export const RE_FINAL_RELEASE = /^v[0-9]+(\.[0-9]+)*$/;
|
|
36
|
+
|
|
37
|
+
export const CONFIG_DIR = join(homedir(), '.claude', 'skills', 'devlog');
|
|
38
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
39
|
+
|
|
40
|
+
export const DEEP_DIVE_DEFAULTS = Object.freeze({
|
|
41
|
+
topicDomains: Object.freeze(['AI', 'DevOps/SRE', 'software engineering']),
|
|
42
|
+
minSources: 3,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export function expandHome(p) {
|
|
46
|
+
if (!p) return p;
|
|
47
|
+
if (p === '~') return homedir();
|
|
48
|
+
if (p.startsWith('~/')) return join(homedir(), p.slice(2));
|
|
49
|
+
return p;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// argv-style invocation; no shell, so user-supplied args cannot inject.
|
|
53
|
+
// Returns trimmed stdout on exit 0, null otherwise.
|
|
54
|
+
export function execArgs(cmd, args, opts = {}) {
|
|
55
|
+
const r = spawnArgs(cmd, args, opts);
|
|
56
|
+
return r.status === 0 ? r.stdout : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Like execArgs but returns { status, stdout, stderr } for callers that need
|
|
60
|
+
// to distinguish failure modes (e.g. a gh 404 vs a network error).
|
|
61
|
+
export function spawnArgs(cmd, args, opts = {}) {
|
|
62
|
+
try {
|
|
63
|
+
const r = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', ...opts });
|
|
64
|
+
return {
|
|
65
|
+
status: r.status ?? 1,
|
|
66
|
+
stdout: (r.stdout || '').trim(),
|
|
67
|
+
stderr: (r.stderr || '').trim(),
|
|
68
|
+
};
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { status: 1, stdout: '', stderr: String(e && e.message || e) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Atomic write: write to sibling tmp file then rename.
|
|
75
|
+
// Prevents readers from seeing a half-written config if process is killed mid-write.
|
|
76
|
+
// Uses `wx` (exclusive create) flag to prevent symlink-attack on shared filesystems
|
|
77
|
+
// — if an attacker pre-creates the tmp file, our write fails rather than following
|
|
78
|
+
// the symlink to a sensitive target.
|
|
79
|
+
export function atomicWriteJSON(path, data) {
|
|
80
|
+
const tmp = path + '.tmp.' + process.pid + '.' + Date.now();
|
|
81
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
82
|
+
try {
|
|
83
|
+
renameSync(tmp, path);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
try { unlinkSync(tmp); } catch {}
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function readConfig(path = CONFIG_PATH) {
|
|
91
|
+
if (!existsSync(path)) return null;
|
|
92
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Validate a config object before writing. Throws with a user-facing message on failure.
|
|
96
|
+
export function validateConfig(config) {
|
|
97
|
+
if (!config || typeof config !== 'object') throw new Error('Config must be an object');
|
|
98
|
+
const required = ['targetRepo', 'gitAuthor', 'githubUser', 'projects'];
|
|
99
|
+
for (const k of required) {
|
|
100
|
+
if (!(k in config)) throw new Error(`Missing required field: ${k}`);
|
|
101
|
+
}
|
|
102
|
+
if (!RE_OWNER_REPO.test(config.targetRepo)) {
|
|
103
|
+
throw new Error(`targetRepo must match <owner>/<repo>: got ${JSON.stringify(config.targetRepo)}`);
|
|
104
|
+
}
|
|
105
|
+
if (typeof config.gitAuthor !== 'string' || config.gitAuthor.length === 0 || SHELL_QUOTE_BREAK.test(config.gitAuthor)) {
|
|
106
|
+
throw new Error(`gitAuthor must be non-empty and contain no shell metacharacters: got ${JSON.stringify(config.gitAuthor)}`);
|
|
107
|
+
}
|
|
108
|
+
if (!RE_GH_USER.test(config.githubUser)) {
|
|
109
|
+
throw new Error(`githubUser must match GitHub username pattern: got ${JSON.stringify(config.githubUser)}`);
|
|
110
|
+
}
|
|
111
|
+
if ('branch' in config) {
|
|
112
|
+
if (!RE_BRANCH.test(config.branch) || FORBIDDEN_BRANCH_PARTS.test(config.branch)) {
|
|
113
|
+
throw new Error(`branch must be a valid git branch name (no leading dash, no '..'): got ${JSON.stringify(config.branch)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if ('voicePath' in config) {
|
|
117
|
+
// Optional: directory holding the voice profile used to write entries. Read by
|
|
118
|
+
// the skill with the Read tool only — never shell-interpolated — so the only
|
|
119
|
+
// hard requirement is no shell metacharacters and no leading dash. A leading `~`
|
|
120
|
+
// is allowed (the skill expands it); we test the expanded form so an absolute
|
|
121
|
+
// path has no `~` left to trip the shell-quote-break check. Existence is checked
|
|
122
|
+
// at prompt time (and at runtime, with a fallback chain), not here.
|
|
123
|
+
const expanded = typeof config.voicePath === 'string' ? expandHome(config.voicePath) : config.voicePath;
|
|
124
|
+
if (typeof config.voicePath !== 'string' || SHELL_QUOTE_BREAK.test(expanded) || expanded.trim().startsWith('-')) {
|
|
125
|
+
throw new Error(`voicePath must be a path with no shell metacharacters and no leading dash: got ${JSON.stringify(config.voicePath)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if ('deepDive' in config) {
|
|
129
|
+
const d = config.deepDive;
|
|
130
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) throw new Error('deepDive must be an object');
|
|
131
|
+
if ('minSources' in d) {
|
|
132
|
+
if (!Number.isInteger(d.minSources) || d.minSources < 1 || d.minSources > 10) {
|
|
133
|
+
throw new Error(`deepDive.minSources must be an integer 1-10: got ${JSON.stringify(d.minSources)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if ('topicDomains' in d) {
|
|
137
|
+
if (!Array.isArray(d.topicDomains) || d.topicDomains.length === 0
|
|
138
|
+
|| d.topicDomains.some((t) => typeof t !== 'string' || t.length === 0 || t.length > 100 || /[\x00-\x1f]/.test(t))) {
|
|
139
|
+
throw new Error('deepDive.topicDomains must be a non-empty array of short strings');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(config.projects)) {
|
|
144
|
+
throw new Error('projects must be an array');
|
|
145
|
+
}
|
|
146
|
+
const seenKeys = new Set();
|
|
147
|
+
for (const p of config.projects) {
|
|
148
|
+
if (!p || typeof p !== 'object') throw new Error('Each project must be an object');
|
|
149
|
+
if (!RE_PROJECT_KEY.test(p.key) || p.key.includes('..')) {
|
|
150
|
+
throw new Error(`project.key invalid: ${JSON.stringify(p.key)}`);
|
|
151
|
+
}
|
|
152
|
+
if (seenKeys.has(p.key)) throw new Error(`Duplicate project key: ${JSON.stringify(p.key)}`);
|
|
153
|
+
seenKeys.add(p.key);
|
|
154
|
+
if (typeof p.path !== 'string' || SHELL_QUOTE_BREAK.test(p.path)) {
|
|
155
|
+
throw new Error(`project.path invalid (must contain no shell metacharacters): ${JSON.stringify(p.path)}`);
|
|
156
|
+
}
|
|
157
|
+
if (!RE_OWNER_REPO.test(p.remote)) {
|
|
158
|
+
throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
|
|
159
|
+
}
|
|
160
|
+
if ('pathFilter' in p) {
|
|
161
|
+
// Optional: scope this project's commits to a repo subdirectory (e.g. a
|
|
162
|
+
// single skill in a monorepo). Interpolated into `git log -- <pathFilter>`,
|
|
163
|
+
// so enforce the same no-metacharacter / no-`..` safety as branch names.
|
|
164
|
+
if (typeof p.pathFilter !== 'string' || !RE_PATH_FILTER.test(p.pathFilter) || FORBIDDEN_BRANCH_PARTS.test(p.pathFilter)) {
|
|
165
|
+
throw new Error(`project.pathFilter must be a repo-relative subdir (no leading dash/slash, no '..', no shell metacharacters): ${JSON.stringify(p.pathFilter)}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if ('tagPrefix' in p) {
|
|
169
|
+
// Optional: the prefix of the git tags that mark this project's releases
|
|
170
|
+
// (e.g. `devlog-v`). Interpolated into `git tag --list '<tagPrefix>*'`, so
|
|
171
|
+
// enforce the same no-metacharacter / no-`..` safety as path filters.
|
|
172
|
+
if (typeof p.tagPrefix !== 'string' || !RE_TAG_PREFIX.test(p.tagPrefix) || FORBIDDEN_BRANCH_PARTS.test(p.tagPrefix)) {
|
|
173
|
+
throw new Error(`project.tagPrefix must be a tag prefix (no leading dash/slash, no '..', no shell metacharacters): ${JSON.stringify(p.tagPrefix)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if ('label' in p) {
|
|
177
|
+
// Label is rendered as React text content only — never shell-interpolated,
|
|
178
|
+
// never used in URLs, never used as a filesystem path. React escapes all
|
|
179
|
+
// text content. Therefore: any string is safe. Apostrophes (e.g.
|
|
180
|
+
// "Mom I'm Bored") and unicode are legitimate label content.
|
|
181
|
+
// INVARIANT: if a future change makes label flow into shell or innerHTML,
|
|
182
|
+
// tighten this validation to SHELL_QUOTE_BREAK at the same time.
|
|
183
|
+
if (typeof p.label !== 'string') throw new Error(`project.label must be a string if present`);
|
|
184
|
+
if (p.label.length > 200) throw new Error(`project.label too long (max 200 chars)`);
|
|
185
|
+
if (/[\x00-\x1f]/.test(p.label)) throw new Error(`project.label contains control characters`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return config;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Resolve the effective deepDive settings (user values over defaults).
|
|
192
|
+
export function resolveDeepDive(config) {
|
|
193
|
+
const d = (config && config.deepDive) || {};
|
|
194
|
+
return {
|
|
195
|
+
topicDomains: Array.isArray(d.topicDomains) && d.topicDomains.length ? d.topicDomains : [...DEEP_DIVE_DEFAULTS.topicDomains],
|
|
196
|
+
minSources: Number.isInteger(d.minSources) ? d.minSources : DEEP_DIVE_DEFAULTS.minSources,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// True when a git remote URL points at <owner>/<repo> on any common transport
|
|
201
|
+
// (https://github.com/o/r.git, git@github.com:o/r.git, ssh://git@github.com/o/r).
|
|
202
|
+
export function remoteUrlMatches(url, ownerRepo) {
|
|
203
|
+
if (!url || !ownerRepo) return false;
|
|
204
|
+
const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
205
|
+
return !!m && m[1].toLowerCase() === ownerRepo.toLowerCase();
|
|
206
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Deterministic post-contract lint. This is the mechanically-checkable subset
|
|
2
|
+
// of the how-to quality contract in SKILL.md Step 6; the judgment calls
|
|
3
|
+
// (reproducibility, gotcha quality, voice) belong to the self-review rubric
|
|
4
|
+
// and the eval judge, not here.
|
|
5
|
+
import { basename } from 'node:path';
|
|
6
|
+
import { RE_FINAL_RELEASE } from './core.mjs';
|
|
7
|
+
|
|
8
|
+
export const FRONTMATTER_KEYS = ['title', 'date', 'project', 'version', 'tags', 'summary'];
|
|
9
|
+
export const REQUIRED_SECTIONS = ['Shipped', 'Gotchas', 'Sources'];
|
|
10
|
+
|
|
11
|
+
const RE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
12
|
+
|
|
13
|
+
// Minimal frontmatter parser: `--- ... ---` fence, `key: value` lines, flow
|
|
14
|
+
// arrays for tags. Prototype-free target object; unknown keys are kept (the
|
|
15
|
+
// contract does not forbid extras) but only allowlisted keys are checked.
|
|
16
|
+
export function parseFrontmatter(content) {
|
|
17
|
+
const lines = content.split('\n');
|
|
18
|
+
if (lines[0]?.trim() !== '---') return { data: null, body: content };
|
|
19
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
20
|
+
if (end === -1) return { data: null, body: content };
|
|
21
|
+
|
|
22
|
+
const data = Object.create(null);
|
|
23
|
+
for (const line of lines.slice(1, end)) {
|
|
24
|
+
const m = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
|
|
25
|
+
if (!m) continue;
|
|
26
|
+
data[m[1]] = parseScalar(m[2]);
|
|
27
|
+
}
|
|
28
|
+
return { data, body: lines.slice(end + 1).join('\n') };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseScalar(raw) {
|
|
32
|
+
const v = raw.trim();
|
|
33
|
+
if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2)
|
|
34
|
+
|| (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) {
|
|
35
|
+
return v.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
if (v.startsWith('[') && v.endsWith(']')) {
|
|
38
|
+
const inner = v.slice(1, -1).trim();
|
|
39
|
+
if (inner === '') return [];
|
|
40
|
+
return inner.split(',').map((t) => parseScalar(t));
|
|
41
|
+
}
|
|
42
|
+
return v;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Split the body into sections keyed by `## ` heading, ignoring headings
|
|
46
|
+
// inside fenced code blocks. Returns [{ heading, content }].
|
|
47
|
+
export function splitSections(body) {
|
|
48
|
+
const sections = [];
|
|
49
|
+
let current = null;
|
|
50
|
+
let inFence = false;
|
|
51
|
+
for (const line of body.split('\n')) {
|
|
52
|
+
if (/^```/.test(line)) inFence = !inFence;
|
|
53
|
+
const h = !inFence && /^##\s+(.+?)\s*$/.exec(line);
|
|
54
|
+
if (h && !line.startsWith('###')) {
|
|
55
|
+
current = { heading: h[1], content: [] };
|
|
56
|
+
sections.push(current);
|
|
57
|
+
} else if (current) {
|
|
58
|
+
current.content.push(line);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return sections.map((s) => ({ heading: s.heading, content: s.content.join('\n') }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Every opening code fence must carry a language tag. Returns 1-based line
|
|
65
|
+
// numbers of untagged openers.
|
|
66
|
+
export function findUntaggedFences(body) {
|
|
67
|
+
const untagged = [];
|
|
68
|
+
let inFence = false;
|
|
69
|
+
body.split('\n').forEach((line, i) => {
|
|
70
|
+
const m = /^```(.*)$/.exec(line);
|
|
71
|
+
if (!m) return;
|
|
72
|
+
if (!inFence) {
|
|
73
|
+
if (m[1].trim() === '') untagged.push(i + 1);
|
|
74
|
+
inFence = true;
|
|
75
|
+
} else {
|
|
76
|
+
inFence = false;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return untagged;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function extractSourceUrls(sectionContent) {
|
|
83
|
+
const urls = new Set();
|
|
84
|
+
for (const m of sectionContent.matchAll(/\]\((https?:\/\/[^)\s]+)\)/g)) {
|
|
85
|
+
urls.add(m[1]);
|
|
86
|
+
}
|
|
87
|
+
return urls;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Lint a post. Returns { ok, findings: [{ rule, message }] }.
|
|
91
|
+
export function lintPost(content, { minSources = 3, filename = null } = {}) {
|
|
92
|
+
const findings = [];
|
|
93
|
+
const add = (rule, message) => findings.push({ rule, message });
|
|
94
|
+
|
|
95
|
+
const { data, body } = parseFrontmatter(content);
|
|
96
|
+
if (!data) {
|
|
97
|
+
add('frontmatter-missing', 'Post must start with a `---` frontmatter block.');
|
|
98
|
+
return { ok: false, findings };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const key of FRONTMATTER_KEYS) {
|
|
102
|
+
const v = data[key];
|
|
103
|
+
const empty = v === undefined || v === '' || (Array.isArray(v) && v.length === 0);
|
|
104
|
+
if (empty) add(`frontmatter-${key}`, `Frontmatter field \`${key}\` is missing or empty.`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof data.date === 'string' && data.date && !RE_DATE.test(data.date)) {
|
|
108
|
+
add('date-format', `\`date\` must be YYYY-MM-DD: got ${JSON.stringify(data.date)}.`);
|
|
109
|
+
}
|
|
110
|
+
if (typeof data.version === 'string' && data.version && !RE_FINAL_RELEASE.test(data.version)) {
|
|
111
|
+
add('version-format', `\`version\` must match v<digits.digits...>: got ${JSON.stringify(data.version)}.`);
|
|
112
|
+
}
|
|
113
|
+
if (typeof data.title === 'string' && data.title) {
|
|
114
|
+
if (/^release\s+v/i.test(data.title) || data.title.trim() === data.version) {
|
|
115
|
+
add('title-style', 'Title must be essay-style, not a "release vX.Y.Z" label.');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(data.tags) && (data.tags.length < 2 || data.tags.length > 5)) {
|
|
119
|
+
add('tags-count', `Expected 2-5 topic tags, got ${data.tags.length}.`);
|
|
120
|
+
}
|
|
121
|
+
if (filename && typeof data.version === 'string' && data.version) {
|
|
122
|
+
const expected = `${data.version}.md`;
|
|
123
|
+
if (basename(filename) !== expected) {
|
|
124
|
+
add('filename-version', `Filename must be \`${expected}\` (got \`${basename(filename)}\`).`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const sections = splitSections(body);
|
|
129
|
+
const byHeading = new Map(sections.map((s) => [s.heading, s]));
|
|
130
|
+
for (const name of REQUIRED_SECTIONS) {
|
|
131
|
+
if (!byHeading.has(name)) add(`section-${name.toLowerCase()}`, `Missing required \`## ${name}\` section.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const gotchas = byHeading.get('Gotchas');
|
|
135
|
+
if (gotchas && gotchas.content.replace(/\s/g, '').length < 40) {
|
|
136
|
+
add('gotchas-empty', 'The `## Gotchas` section is present but effectively empty.');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sources = byHeading.get('Sources');
|
|
140
|
+
if (sources) {
|
|
141
|
+
const urls = extractSourceUrls(sources.content);
|
|
142
|
+
if (urls.size < minSources) {
|
|
143
|
+
add('sources-count', `Need at least ${minSources} distinct source URLs; found ${urls.size}.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const line of findUntaggedFences(body)) {
|
|
148
|
+
add('fence-untagged', `Code fence at body line ${line} has no language tag.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { ok: findings.length === 0, findings };
|
|
152
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Move a drafted entry into the target-repo clone and update the project's
|
|
2
|
+
// manifest. This is the code-enforced immutability guard: a cut release's
|
|
3
|
+
// entry is never overwritten, and manifest mutation is no longer done by
|
|
4
|
+
// hand-editing JSON in the agent loop.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, copyFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { RE_PROJECT_KEY, RE_FINAL_RELEASE, atomicWriteJSON } from './core.mjs';
|
|
8
|
+
import { parseFrontmatter } from './lint_post.mjs';
|
|
9
|
+
|
|
10
|
+
// Newest-first by date; ties keep insertion order (Array.prototype.sort is
|
|
11
|
+
// stable). Date order is normally also version order, but a backported tag
|
|
12
|
+
// (v1.9.1 tagged after v2.0.0) can diverge — sorting by date matches how the
|
|
13
|
+
// feed renders.
|
|
14
|
+
function sortEntries(entries) {
|
|
15
|
+
return entries.slice().sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function publishEntry({ cloneDir, project, version, entryPath }) {
|
|
19
|
+
if (!RE_PROJECT_KEY.test(project) || project.includes('..')) {
|
|
20
|
+
throw new Error(`Invalid project key: ${JSON.stringify(project)}`);
|
|
21
|
+
}
|
|
22
|
+
if (!RE_FINAL_RELEASE.test(version)) {
|
|
23
|
+
throw new Error(`Invalid version label (must be v<digits.digits...>): ${JSON.stringify(version)}`);
|
|
24
|
+
}
|
|
25
|
+
if (!existsSync(cloneDir)) throw new Error(`Clone directory not found: ${cloneDir}`);
|
|
26
|
+
if (!existsSync(entryPath)) throw new Error(`Entry draft not found: ${entryPath}`);
|
|
27
|
+
|
|
28
|
+
const projectDir = join(cloneDir, project);
|
|
29
|
+
const destPath = join(projectDir, `${version}.md`);
|
|
30
|
+
if (existsSync(destPath)) {
|
|
31
|
+
throw new Error(`Entry ${project}/${version}.md already exists — a cut release is immutable, refusing to overwrite.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const content = readFileSync(entryPath, 'utf8');
|
|
35
|
+
const { data } = parseFrontmatter(content);
|
|
36
|
+
if (!data || !data.title || !data.date || !data.summary) {
|
|
37
|
+
throw new Error('Entry frontmatter must include title, date, and summary (run lint-post first).');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
mkdirSync(projectDir, { recursive: true });
|
|
41
|
+
copyFileSync(entryPath, destPath);
|
|
42
|
+
|
|
43
|
+
const manifestPath = join(projectDir, 'manifest.json');
|
|
44
|
+
let manifest = { entries: [] };
|
|
45
|
+
if (existsSync(manifestPath)) {
|
|
46
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
47
|
+
if (!manifest || !Array.isArray(manifest.entries)) {
|
|
48
|
+
throw new Error(`Malformed manifest at ${manifestPath}: expected { "entries": [...] }.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const file = `${version}.md`;
|
|
53
|
+
// Idempotent: legacy manifests may already reference this file/version even
|
|
54
|
+
// when the .md was missing — never duplicate an index row.
|
|
55
|
+
const already = manifest.entries.some((e) => e && (e.file === file || e.version === version));
|
|
56
|
+
let manifestUpdated = false;
|
|
57
|
+
if (!already) {
|
|
58
|
+
manifest.entries.push({
|
|
59
|
+
date: String(data.date),
|
|
60
|
+
file,
|
|
61
|
+
title: String(data.title),
|
|
62
|
+
summary: String(data.summary),
|
|
63
|
+
version,
|
|
64
|
+
});
|
|
65
|
+
manifest.entries = sortEntries(manifest.entries);
|
|
66
|
+
atomicWriteJSON(manifestPath, manifest);
|
|
67
|
+
manifestUpdated = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { written: destPath, manifestUpdated };
|
|
71
|
+
}
|
package/lib/scan.mjs
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Release discovery: turns config + local git state into a JSON plan of the
|
|
2
|
+
// entries /devlog should write. Replaces the hand-rolled bash steps the skill
|
|
3
|
+
// used to run — tag names never pass through a shell here (spawnSync argv only),
|
|
4
|
+
// and the semver/range logic is unit-testable.
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import {
|
|
7
|
+
SHELL_QUOTE_BREAK,
|
|
8
|
+
RE_FINAL_RELEASE,
|
|
9
|
+
execArgs,
|
|
10
|
+
spawnArgs,
|
|
11
|
+
remoteUrlMatches,
|
|
12
|
+
resolveDeepDive,
|
|
13
|
+
} from './core.mjs';
|
|
14
|
+
|
|
15
|
+
const DIFFSTAT_MAX_CHARS = 5000;
|
|
16
|
+
|
|
17
|
+
// The version label is the substring of the tag starting at the first `v`
|
|
18
|
+
// that is followed by a digit: `devlog-v0.2.0` → `v0.2.0`, `v1.4.0` → `v1.4.0`.
|
|
19
|
+
// Returns null when the tag has no such sequence (e.g. `version-bump`).
|
|
20
|
+
export function deriveVersionLabel(tag) {
|
|
21
|
+
const m = /v(?=\d)/.exec(tag);
|
|
22
|
+
return m ? tag.slice(m.index) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Tag names come from `git tag --list` and are attacker-influenceable (anyone
|
|
26
|
+
// who can push a tag controls them). Even though this module never puts them
|
|
27
|
+
// through a shell, unsafe names are excluded outright so downstream consumers
|
|
28
|
+
// (the skill, filenames, URLs) never see them.
|
|
29
|
+
export function isSafeTagName(tag) {
|
|
30
|
+
return typeof tag === 'string' && tag.length > 0 && tag.length <= 200
|
|
31
|
+
&& !SHELL_QUOTE_BREAK.test(tag) && !tag.startsWith('-');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isFinalRelease(label) {
|
|
35
|
+
return typeof label === 'string' && RE_FINAL_RELEASE.test(label);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Partition a descending (`--sort=-v:refname`) tag list into final releases
|
|
39
|
+
// and skipped tags with reasons. Order is preserved, so releases[i + 1] is the
|
|
40
|
+
// range base (prevTag) of releases[i].
|
|
41
|
+
export function selectReleases(tags) {
|
|
42
|
+
const releases = [];
|
|
43
|
+
const skipped = [];
|
|
44
|
+
for (const tag of tags) {
|
|
45
|
+
if (!isSafeTagName(tag)) {
|
|
46
|
+
skipped.push({ tag, reason: 'unsafe-name' });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const version = deriveVersionLabel(tag);
|
|
50
|
+
if (!version) {
|
|
51
|
+
skipped.push({ tag, reason: 'non-release' });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!isFinalRelease(version)) {
|
|
55
|
+
const reason = version.includes('-') ? 'prerelease'
|
|
56
|
+
: version.includes('+') ? 'build-metadata' : 'non-final';
|
|
57
|
+
skipped.push({ tag, reason });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
releases.push({ tag, version });
|
|
61
|
+
}
|
|
62
|
+
return { releases, skipped };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function git(projectPath, args) {
|
|
66
|
+
return execArgs('git', ['-C', projectPath, ...args]);
|
|
67
|
+
}
|
|
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() } = {}) {
|
|
73
|
+
const out = {
|
|
74
|
+
key: project.key,
|
|
75
|
+
label: project.label || project.key,
|
|
76
|
+
remote: project.remote,
|
|
77
|
+
path: project.path,
|
|
78
|
+
pathFilter: project.pathFilter || null,
|
|
79
|
+
tagPrefix: project.tagPrefix || 'v',
|
|
80
|
+
tagFetch: 'skipped',
|
|
81
|
+
newReleases: [],
|
|
82
|
+
skippedTags: [],
|
|
83
|
+
error: null,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (!existsSync(project.path)) {
|
|
87
|
+
out.error = 'path-missing';
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (fetch) {
|
|
92
|
+
// Best-effort: releases are commonly cut by CI on the remote, so the tag is
|
|
93
|
+
// born there. Offline / no-remote / auth failures degrade to local tags.
|
|
94
|
+
const r = spawnArgs('git', ['-C', project.path, 'fetch', '--tags', '--quiet']);
|
|
95
|
+
out.tagFetch = r.status === 0 ? 'ok' : 'failed';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const listed = git(project.path, ['tag', '--list', `${out.tagPrefix}*`, '--sort=-v:refname']);
|
|
99
|
+
const tags = listed ? listed.split('\n').filter(Boolean) : [];
|
|
100
|
+
const { releases, skipped } = selectReleases(tags);
|
|
101
|
+
out.skippedTags.push(...skipped);
|
|
102
|
+
|
|
103
|
+
// Publicness scaffolding, computed once per project.
|
|
104
|
+
const originUrl = git(project.path, ['remote', 'get-url', 'origin']);
|
|
105
|
+
const remoteMatches = remoteUrlMatches(originUrl, project.remote);
|
|
106
|
+
const publishedRef = `refs/remotes/origin/${branch}`;
|
|
107
|
+
const hasPublishedRef = git(project.path, ['rev-parse', '--verify', '--quiet', publishedRef]) !== null;
|
|
108
|
+
|
|
109
|
+
const isPublic = (rev) => {
|
|
110
|
+
if (!remoteMatches || !hasPublishedRef) return false;
|
|
111
|
+
return spawnArgs('git', ['-C', project.path, 'merge-base', '--is-ancestor', rev, publishedRef]).status === 0;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < releases.length; i++) {
|
|
115
|
+
const { tag, version } = releases[i];
|
|
116
|
+
if (existingFiles.has(`${version}.md`)) {
|
|
117
|
+
out.skippedTags.push({ tag, reason: 'entry-exists' });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// prevTag comes from the FILTERED final-release list only — never a raw
|
|
122
|
+
// prerelease or non-release tag — so ranges are always release-to-release.
|
|
123
|
+
const prevTag = releases[i + 1]?.tag ?? null;
|
|
124
|
+
const range = prevTag ? `${prevTag}..${tag}` : tag;
|
|
125
|
+
const logArgs = ['log', range, '--format=%H|%s|%cs'];
|
|
126
|
+
if (project.pathFilter) logArgs.push('--', project.pathFilter);
|
|
127
|
+
const logOut = git(project.path, logArgs);
|
|
128
|
+
if (logOut === null) {
|
|
129
|
+
out.skippedTags.push({ tag, reason: 'log-failed' });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const lines = logOut.split('\n').filter(Boolean);
|
|
134
|
+
if (lines.length === 0) {
|
|
135
|
+
// Nothing shipped for this project in that version (common under
|
|
136
|
+
// pathFilter in a monorepo when the tag belongs to another subdir).
|
|
137
|
+
out.skippedTags.push({ tag, reason: 'empty-range' });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Fast path: if the tag's commit is on the published branch, every commit
|
|
142
|
+
// in the range (all reachable from the tag) is public — one git call
|
|
143
|
+
// instead of one per commit.
|
|
144
|
+
const tagPublic = isPublic(`${tag}^{commit}`);
|
|
145
|
+
const commits = lines.map((line) => {
|
|
146
|
+
const [hash, subject, date] = splitLogLine(line);
|
|
147
|
+
return { hash, subject, date, public: tagPublic || isPublic(hash) };
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const release = {
|
|
151
|
+
tag,
|
|
152
|
+
version,
|
|
153
|
+
date: git(project.path, ['log', '-1', '--format=%cs', `${tag}^{commit}`]) || null,
|
|
154
|
+
prevTag,
|
|
155
|
+
commits,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (prevTag) {
|
|
159
|
+
const diffArgs = ['diff', '--stat', '--stat-count=40', range];
|
|
160
|
+
if (project.pathFilter) diffArgs.push('--', project.pathFilter);
|
|
161
|
+
const stat = git(project.path, diffArgs);
|
|
162
|
+
if (stat) release.diffstat = stat.slice(0, DIFFSTAT_MAX_CHARS);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
out.newReleases.push(release);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// `%H|%s|%cs` — the subject may itself contain `|`, so split off the leading
|
|
172
|
+
// hash and trailing date and keep everything between as the subject.
|
|
173
|
+
function splitLogLine(line) {
|
|
174
|
+
const first = line.indexOf('|');
|
|
175
|
+
const last = line.lastIndexOf('|');
|
|
176
|
+
const hash = line.slice(0, first);
|
|
177
|
+
const subject = line.slice(first + 1, last);
|
|
178
|
+
const date = line.slice(last + 1);
|
|
179
|
+
return [hash, subject, date];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Which entry files already exist in the target repo for one project — a
|
|
183
|
+
// single `gh api` directory listing (vs. the old one-probe-per-tag pattern).
|
|
184
|
+
// Returns { files: Set, status: 'ok' | 'empty' | 'failed' }: a 404 means the
|
|
185
|
+
// project has no entries yet; any other failure is surfaced so the caller
|
|
186
|
+
// knows the entry-exists filter may be incomplete (publish-entry still refuses
|
|
187
|
+
// overwrites against the fresh clone, so a stale scan cannot clobber anything).
|
|
188
|
+
export function fetchExistingEntries(targetRepo, branch, projectKey) {
|
|
189
|
+
const r = spawnArgs('gh', ['api', `repos/${targetRepo}/contents/${projectKey}?ref=${branch}`, '--jq', '.[].name']);
|
|
190
|
+
if (r.status === 0) {
|
|
191
|
+
return { files: new Set(r.stdout.split('\n').filter(Boolean)), status: 'ok' };
|
|
192
|
+
}
|
|
193
|
+
if (/HTTP 404|Not Found/i.test(r.stderr)) {
|
|
194
|
+
return { files: new Set(), status: 'empty' };
|
|
195
|
+
}
|
|
196
|
+
return { files: new Set(), status: 'failed' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Full scan across the configured projects. `getExisting` is injectable for
|
|
200
|
+
// tests; production uses fetchExistingEntries against the GitHub API.
|
|
201
|
+
export function scanAll(config, { projectKey = null, fetch = true, getExisting = fetchExistingEntries } = {}) {
|
|
202
|
+
const branch = config.branch || 'main';
|
|
203
|
+
let projects = config.projects;
|
|
204
|
+
if (projectKey) {
|
|
205
|
+
projects = projects.filter((p) => p.key === projectKey);
|
|
206
|
+
if (projects.length === 0) {
|
|
207
|
+
return {
|
|
208
|
+
error: `unknown-project: ${projectKey}`,
|
|
209
|
+
availableKeys: config.projects.map((p) => p.key),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const results = projects.map((project) => {
|
|
215
|
+
const existing = getExisting(config.targetRepo, branch, project.key);
|
|
216
|
+
const scanned = scanProject(project, { branch, fetch, existingFiles: existing.files });
|
|
217
|
+
scanned.existenceCheck = existing.status;
|
|
218
|
+
return scanned;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
targetRepo: config.targetRepo,
|
|
223
|
+
branch,
|
|
224
|
+
deepDive: resolveDeepDive(config),
|
|
225
|
+
voicePath: config.voicePath || null,
|
|
226
|
+
projects: results,
|
|
227
|
+
totalNewReleases: results.reduce((n, p) => n + p.newReleases.length, 0),
|
|
228
|
+
};
|
|
229
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"bin/",
|
|
30
|
+
"lib/",
|
|
31
|
+
"evals/",
|
|
32
|
+
"skill-invariants.json",
|
|
30
33
|
"preview/",
|
|
31
34
|
"examples/",
|
|
32
35
|
"voice/",
|