@deskwork/core 0.9.5 → 0.9.6

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.
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Rule: missing-frontmatter-id.
3
+ *
4
+ * Audit: every calendar entry whose `id` is not a key in `index.byId`
5
+ * is a finding. The file binding for that entry isn't there yet.
6
+ *
7
+ * Repair: search the content tree for candidate files. Three searches,
8
+ * widening:
9
+ * 1. The file at the slug-template path (today's behavior — pre-Phase-19
10
+ * scaffolds and most Astro flat layouts).
11
+ * 2. Any file whose frontmatter `title` matches the entry title.
12
+ * 3. Any file whose basename (without extension) matches the slug.
13
+ *
14
+ * If exactly one candidate emerges, the plan is "write `id: <entry.id>`
15
+ * into <path>". Multiple candidates produce a prompt the operator
16
+ * resolves; `--yes` mode skips. Zero candidates is reported and skipped
17
+ * (nothing to repair without operator input — the file may not exist
18
+ * at all yet).
19
+ *
20
+ * Sibling-relative imports per the project convention.
21
+ */
22
+
23
+ import { existsSync, readdirSync, statSync } from 'node:fs';
24
+ import { basename, extname, join, relative } from 'node:path';
25
+ import type { CalendarEntry } from '../../types.ts';
26
+ import { resolveBlogFilePath, resolveContentDir } from '../../paths.ts';
27
+ import { readFrontmatter, updateFrontmatter } from '../../frontmatter.ts';
28
+ import { readFileSync, writeFileSync } from 'node:fs';
29
+ import type {
30
+ DoctorContext,
31
+ DoctorRule,
32
+ Finding,
33
+ RepairPlan,
34
+ RepairResult,
35
+ } from '../types.ts';
36
+
37
+ const RULE_ID = 'missing-frontmatter-id';
38
+
39
+ const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx', '.markdown']);
40
+ const SKIP_DIRS = new Set(['scrapbook', 'node_modules', 'dist', '.git']);
41
+
42
+ interface CandidateFile {
43
+ /** Absolute path to the candidate. */
44
+ absolutePath: string;
45
+ /** Why this file was considered (for operator-facing labels). */
46
+ matchReason: 'template-path' | 'title-match' | 'basename-match';
47
+ }
48
+
49
+ function shouldSkipDir(name: string): boolean {
50
+ if (name.startsWith('.')) return true;
51
+ return SKIP_DIRS.has(name.toLowerCase());
52
+ }
53
+
54
+ function collectMarkdownFiles(dir: string): string[] {
55
+ const out: string[] = [];
56
+ visit(dir);
57
+ out.sort();
58
+ return out;
59
+
60
+ function visit(currentDir: string): void {
61
+ let names: string[];
62
+ try {
63
+ names = readdirSync(currentDir);
64
+ } catch {
65
+ return;
66
+ }
67
+ for (const name of names) {
68
+ const abs = join(currentDir, name);
69
+ let st;
70
+ try {
71
+ st = statSync(abs);
72
+ } catch {
73
+ continue;
74
+ }
75
+ if (st.isDirectory()) {
76
+ if (shouldSkipDir(name)) continue;
77
+ visit(abs);
78
+ continue;
79
+ }
80
+ if (!st.isFile()) continue;
81
+ if (MARKDOWN_EXTENSIONS.has(extname(name).toLowerCase())) {
82
+ out.push(abs);
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ function readTitle(absPath: string): string | undefined {
89
+ try {
90
+ const parsed = readFrontmatter(absPath);
91
+ const t = parsed.data.title;
92
+ return typeof t === 'string' ? t : undefined;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ function basenameNoExt(p: string): string {
99
+ return basename(p, extname(p));
100
+ }
101
+
102
+ /**
103
+ * Search the content tree for files that could be bound to `entry`.
104
+ * Excludes files already claiming a (different) id — those are not
105
+ * candidates here; the duplicate-id and orphan rules handle them.
106
+ */
107
+ export function findCandidatesForEntry(
108
+ projectRoot: string,
109
+ config: DoctorContext['config'],
110
+ site: string,
111
+ entry: CalendarEntry,
112
+ ): CandidateFile[] {
113
+ const candidates: CandidateFile[] = [];
114
+ const seen = new Set<string>();
115
+ const contentDir = resolveContentDir(projectRoot, config, site);
116
+
117
+ function consider(abs: string, reason: CandidateFile['matchReason']): void {
118
+ if (!existsSync(abs)) return;
119
+ if (seen.has(abs)) return;
120
+ // Skip files whose frontmatter already has a deskwork.id (they
121
+ // belong to another entry or are duplicates — out of this rule's
122
+ // scope). Top-level `id:` is the operator's keyspace per Issue #38
123
+ // and doesn't disqualify a file as a candidate.
124
+ try {
125
+ const parsed = readFrontmatter(abs);
126
+ const existingId = readDeskworkId(parsed.data);
127
+ if (existingId !== undefined) {
128
+ return;
129
+ }
130
+ } catch {
131
+ // Unreadable frontmatter — skip; the index already reports it.
132
+ return;
133
+ }
134
+ seen.add(abs);
135
+ candidates.push({ absolutePath: abs, matchReason: reason });
136
+ }
137
+
138
+ // 1. Slug-template path.
139
+ const templatePath = resolveBlogFilePath(
140
+ projectRoot,
141
+ config,
142
+ site,
143
+ entry.slug,
144
+ );
145
+ consider(templatePath, 'template-path');
146
+
147
+ // 2. + 3. Walk content tree once, match by title and basename.
148
+ let files: string[];
149
+ try {
150
+ files = collectMarkdownFiles(contentDir);
151
+ } catch {
152
+ return candidates;
153
+ }
154
+ const slugBasename = entry.slug.split('/').pop() ?? entry.slug;
155
+ for (const abs of files) {
156
+ if (seen.has(abs)) continue;
157
+ const title = readTitle(abs);
158
+ if (title !== undefined && title.trim() === entry.title.trim()) {
159
+ consider(abs, 'title-match');
160
+ continue;
161
+ }
162
+ const bn = basenameNoExt(abs);
163
+ if (bn === slugBasename) {
164
+ consider(abs, 'basename-match');
165
+ }
166
+ }
167
+
168
+ return candidates;
169
+ }
170
+
171
+ /**
172
+ * Write `deskwork.id: <entryId>` into the markdown file's frontmatter.
173
+ * Idempotent when the file already carries the same id (no-op write
174
+ * avoided). Issue #38: writes under the `deskwork:` namespace, not the
175
+ * global top-level `id:`.
176
+ */
177
+ export function bindFrontmatterId(absPath: string, entryId: string): void {
178
+ const raw = readFileSync(absPath, 'utf-8');
179
+ const updated = updateFrontmatter(raw, { deskwork: { id: entryId } });
180
+ if (updated === raw) return;
181
+ writeFileSync(absPath, updated, 'utf-8');
182
+ }
183
+
184
+ /**
185
+ * Read `deskwork.id` from frontmatter data. Returns `undefined` when
186
+ * either the `deskwork` block or the nested `id` is missing or
187
+ * malformed. Used by candidate filters and the migration rule to keep
188
+ * the namespaced-key reads consistent across rules.
189
+ */
190
+ function readDeskworkId(data: Record<string, unknown>): string | undefined {
191
+ const block = data.deskwork;
192
+ if (block === undefined || block === null) return undefined;
193
+ if (typeof block !== 'object' || Array.isArray(block)) return undefined;
194
+ const id = (block as Record<string, unknown>).id;
195
+ if (typeof id !== 'string') return undefined;
196
+ const trimmed = id.trim();
197
+ return trimmed === '' ? undefined : trimmed;
198
+ }
199
+
200
+ const rule: DoctorRule = {
201
+ id: RULE_ID,
202
+ label: 'Calendar entries with no matching frontmatter id',
203
+
204
+ async audit(ctx: DoctorContext): Promise<Finding[]> {
205
+ const findings: Finding[] = [];
206
+ for (const entry of ctx.calendar.entries) {
207
+ if (!entry.id) {
208
+ // Calendar entries without an id are reported by
209
+ // calendar-uuid-missing instead — keep concerns separate.
210
+ continue;
211
+ }
212
+ if (ctx.index.byId.has(entry.id)) continue;
213
+ findings.push({
214
+ ruleId: RULE_ID,
215
+ site: ctx.site,
216
+ severity: 'warning',
217
+ message: `Entry "${entry.slug}" (id ${entry.id}) is not bound to any file via frontmatter id`,
218
+ details: {
219
+ entryId: entry.id,
220
+ slug: entry.slug,
221
+ title: entry.title,
222
+ stage: entry.stage,
223
+ },
224
+ });
225
+ }
226
+ return findings;
227
+ },
228
+
229
+ async plan(ctx: DoctorContext, finding: Finding): Promise<RepairPlan> {
230
+ const entryId = String(finding.details.entryId ?? '');
231
+ const entry = ctx.calendar.entries.find((e) => e.id === entryId);
232
+ if (!entry) {
233
+ return {
234
+ kind: 'report-only',
235
+ finding,
236
+ reason: `entry id ${entryId} no longer present in calendar — re-run audit`,
237
+ };
238
+ }
239
+ const candidates = findCandidatesForEntry(
240
+ ctx.projectRoot,
241
+ ctx.config,
242
+ ctx.site,
243
+ entry,
244
+ );
245
+ const contentDir = resolveContentDir(ctx.projectRoot, ctx.config, ctx.site);
246
+
247
+ if (candidates.length === 0) {
248
+ return {
249
+ kind: 'report-only',
250
+ finding,
251
+ reason:
252
+ `no candidate file found under ${contentDir} for slug "${entry.slug}". ` +
253
+ 'Create the file (e.g. via `deskwork outline`) or move an existing one ' +
254
+ 'into place, then re-run.',
255
+ };
256
+ }
257
+ if (candidates.length === 1) {
258
+ const c = candidates[0];
259
+ return {
260
+ kind: 'apply',
261
+ finding,
262
+ summary: `write \`id: ${entry.id}\` into ${relative(ctx.projectRoot, c.absolutePath)} (${c.matchReason})`,
263
+ payload: { absolutePath: c.absolutePath, entryId: entry.id },
264
+ };
265
+ }
266
+ return {
267
+ kind: 'prompt',
268
+ finding,
269
+ question: `Multiple candidate files for entry "${entry.slug}" (id ${entry.id}). Which file should carry the id?`,
270
+ choices: candidates.map((c) => ({
271
+ id: c.absolutePath,
272
+ label: `${relative(ctx.projectRoot, c.absolutePath)} (${c.matchReason})`,
273
+ payload: { absolutePath: c.absolutePath, entryId: entry.id },
274
+ })),
275
+ };
276
+ },
277
+
278
+ async apply(ctx: DoctorContext, plan: RepairPlan): Promise<RepairResult> {
279
+ if (plan.kind !== 'apply') {
280
+ return {
281
+ finding: plan.finding,
282
+ applied: false,
283
+ message: 'plan is not directly appliable; runner should resolve prompt first',
284
+ skipReason: 'apply-failed',
285
+ };
286
+ }
287
+ const absPath = String(plan.payload.absolutePath ?? '');
288
+ const entryId = String(plan.payload.entryId ?? '');
289
+ if (!absPath || !entryId) {
290
+ return {
291
+ finding: plan.finding,
292
+ applied: false,
293
+ message: 'apply payload missing absolutePath or entryId',
294
+ skipReason: 'apply-failed',
295
+ };
296
+ }
297
+ try {
298
+ bindFrontmatterId(absPath, entryId);
299
+ } catch (err) {
300
+ const reason = err instanceof Error ? err.message : String(err);
301
+ return {
302
+ finding: plan.finding,
303
+ applied: false,
304
+ message: `failed to write frontmatter id: ${reason}`,
305
+ skipReason: 'apply-failed',
306
+ };
307
+ }
308
+ return {
309
+ finding: plan.finding,
310
+ applied: true,
311
+ message: `wrote id ${entryId} to ${relative(ctx.projectRoot, absPath)}`,
312
+ details: { absolutePath: absPath, entryId },
313
+ };
314
+ },
315
+ };
316
+
317
+ export default rule;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Rule: orphan-frontmatter-id.
3
+ *
4
+ * Audit: every entry in the content index whose id has no matching
5
+ * calendar entry. The file is bound to "something", but the calendar
6
+ * doesn't know about it.
7
+ *
8
+ * Repair: there are three plausible operator intents — (a) add a
9
+ * calendar row for the file, (b) clear the orphan id from the file
10
+ * (un-bind), or (c) leave it alone. Without a way to gather the
11
+ * intent, the rule reports findings and presents a prompt; with
12
+ * `--yes`, the safest action is "do nothing" — auto-creating
13
+ * calendar rows or auto-deleting frontmatter is destructive.
14
+ */
15
+
16
+ import { readFileSync, writeFileSync } from 'node:fs';
17
+ import { relative } from 'node:path';
18
+ import { parseFrontmatter, removeFrontmatterPaths } from '../../frontmatter.ts';
19
+ import type {
20
+ DoctorContext,
21
+ DoctorRule,
22
+ Finding,
23
+ RepairPlan,
24
+ RepairResult,
25
+ } from '../types.ts';
26
+
27
+ const RULE_ID = 'orphan-frontmatter-id';
28
+
29
+ /**
30
+ * Clear the `deskwork.id` field from a markdown file's frontmatter.
31
+ * Returns true when the field was present and cleared; false when there
32
+ * was nothing to clear. Issue #38: scoped to the namespaced key — top-
33
+ * level `id:` belongs to the operator and is left alone.
34
+ *
35
+ * Uses the round-trip-preserving emitter so untouched keys keep their
36
+ * exact bytes (quoting, comments, ordering). Empty `deskwork:` blocks
37
+ * are pruned via `removeFrontmatterPaths`.
38
+ */
39
+ function clearFrontmatterId(absPath: string): boolean {
40
+ const raw = readFileSync(absPath, 'utf-8');
41
+ const { data } = parseFrontmatter(raw);
42
+ const block = data.deskwork;
43
+ if (block === undefined || block === null) return false;
44
+ if (typeof block !== 'object' || Array.isArray(block)) return false;
45
+ const blockObj = block as Record<string, unknown>;
46
+ if (!('id' in blockObj)) return false;
47
+
48
+ const updated = removeFrontmatterPaths(raw, [['deskwork', 'id']]);
49
+ if (updated === raw) return false;
50
+ writeFileSync(absPath, updated, 'utf-8');
51
+ return true;
52
+ }
53
+
54
+ const rule: DoctorRule = {
55
+ id: RULE_ID,
56
+ label: 'Files with frontmatter ids that are not in the calendar',
57
+
58
+ async audit(ctx: DoctorContext): Promise<Finding[]> {
59
+ const findings: Finding[] = [];
60
+ const calendarIds = new Set<string>();
61
+ for (const e of ctx.calendar.entries) {
62
+ if (e.id) calendarIds.add(e.id);
63
+ }
64
+ for (const [id, absPath] of ctx.index.byId) {
65
+ if (calendarIds.has(id)) continue;
66
+ findings.push({
67
+ ruleId: RULE_ID,
68
+ site: ctx.site,
69
+ severity: 'warning',
70
+ message: `File ${relative(ctx.projectRoot, absPath)} carries id ${id}, which is not in the calendar`,
71
+ details: { absolutePath: absPath, entryId: id },
72
+ });
73
+ }
74
+ return findings;
75
+ },
76
+
77
+ async plan(_ctx: DoctorContext, finding: Finding): Promise<RepairPlan> {
78
+ const absPath = String(finding.details.absolutePath ?? '');
79
+ const entryId = String(finding.details.entryId ?? '');
80
+ return {
81
+ kind: 'prompt',
82
+ finding,
83
+ question: `File ${absPath} has id ${entryId} but no calendar entry matches. Pick an action:`,
84
+ choices: [
85
+ {
86
+ id: 'none',
87
+ label: 'leave as-is (default; review manually)',
88
+ payload: { action: 'none' },
89
+ },
90
+ {
91
+ id: 'clear-id',
92
+ label: `clear the id from ${absPath} (un-bind the file)`,
93
+ payload: { action: 'clear-id', absolutePath: absPath },
94
+ },
95
+ ],
96
+ };
97
+ },
98
+
99
+ async apply(ctx: DoctorContext, plan: RepairPlan): Promise<RepairResult> {
100
+ if (plan.kind !== 'apply') {
101
+ return {
102
+ finding: plan.finding,
103
+ applied: false,
104
+ message: 'plan is not directly appliable; runner should resolve prompt first',
105
+ skipReason: 'apply-failed',
106
+ };
107
+ }
108
+ const action = String(plan.payload.action ?? '');
109
+ if (action === 'none') {
110
+ return {
111
+ finding: plan.finding,
112
+ applied: false,
113
+ message: 'left file unchanged per operator choice',
114
+ skipReason: 'no-action-needed',
115
+ };
116
+ }
117
+ if (action === 'clear-id') {
118
+ const absPath = String(plan.payload.absolutePath ?? '');
119
+ if (!absPath) {
120
+ return {
121
+ finding: plan.finding,
122
+ applied: false,
123
+ message: 'clear-id apply payload missing absolutePath',
124
+ skipReason: 'apply-failed',
125
+ };
126
+ }
127
+ try {
128
+ const changed = clearFrontmatterId(absPath);
129
+ if (!changed) {
130
+ return {
131
+ finding: plan.finding,
132
+ applied: false,
133
+ message: `no id field in ${relative(ctx.projectRoot, absPath)} to clear`,
134
+ skipReason: 'no-action-needed',
135
+ };
136
+ }
137
+ } catch (err) {
138
+ const reason = err instanceof Error ? err.message : String(err);
139
+ return {
140
+ finding: plan.finding,
141
+ applied: false,
142
+ message: `failed to clear frontmatter id: ${reason}`,
143
+ skipReason: 'apply-failed',
144
+ };
145
+ }
146
+ return {
147
+ finding: plan.finding,
148
+ applied: true,
149
+ message: `cleared id from ${relative(ctx.projectRoot, absPath)}`,
150
+ details: { absolutePath: absPath },
151
+ };
152
+ }
153
+ return {
154
+ finding: plan.finding,
155
+ applied: false,
156
+ message: `unknown apply action: ${action}`,
157
+ skipReason: 'apply-failed',
158
+ };
159
+ },
160
+ };
161
+
162
+ export default rule;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Rule: schema-rejected.
3
+ *
4
+ * The host's content collection schema may reject the `id` frontmatter
5
+ * field that deskwork relies on for the calendar/file binding. We
6
+ * detect this at *write time* in other code paths (scaffolder, the
7
+ * other rules' apply()), not by an active probe — running an actual
8
+ * Astro build inside doctor would be slow and project-specific.
9
+ *
10
+ * This rule's audit always returns empty for that reason. The
11
+ * `printSchemaPatchInstructions` helper is the user-facing surface;
12
+ * other rules (and the CLI command) call it when they observe an
13
+ * actual schema rejection. Phase 19b-followup may add an active
14
+ * probe (write a tmpfile to contentDir, attempt astro check, etc.)
15
+ * once the integration cost is justified.
16
+ */
17
+
18
+ import { printSchemaPatchInstructions } from '../schema-patch.ts';
19
+ import type {
20
+ DoctorContext,
21
+ DoctorRule,
22
+ Finding,
23
+ RepairPlan,
24
+ RepairResult,
25
+ } from '../types.ts';
26
+
27
+ const RULE_ID = 'schema-rejected';
28
+
29
+ const rule: DoctorRule = {
30
+ id: RULE_ID,
31
+ label: "Host's content schema rejects the `id` frontmatter field",
32
+
33
+ async audit(_ctx: DoctorContext): Promise<Finding[]> {
34
+ // Passive — see file header. Other code paths surface schema-patch
35
+ // instructions when they observe an actual rejection.
36
+ return [];
37
+ },
38
+
39
+ async plan(_ctx: DoctorContext, finding: Finding): Promise<RepairPlan> {
40
+ return {
41
+ kind: 'report-only',
42
+ finding,
43
+ reason: printSchemaPatchInstructions(),
44
+ };
45
+ },
46
+
47
+ async apply(_ctx: DoctorContext, plan: RepairPlan): Promise<RepairResult> {
48
+ return {
49
+ finding: plan.finding,
50
+ applied: false,
51
+ message:
52
+ 'schema-rejected has no automatic repair — operator must patch the host content schema',
53
+ skipReason: 'schema-rejected',
54
+ };
55
+ },
56
+ };
57
+
58
+ export default rule;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Rule: slug-collision.
3
+ *
4
+ * Audit: two or more calendar entries share the same slug. With UUID
5
+ * identity this is no longer a hard error (joins go through id), but
6
+ * it's still a bug — the host renderer maps URLs by slug, so two
7
+ * entries claiming the same slug produces duplicate or hidden public
8
+ * URLs.
9
+ *
10
+ * Repair: rename one slug. Doctor doesn't pick which one — that's an
11
+ * editorial decision (which entry "owns" the public URL). The rule
12
+ * reports findings; with no interactive UI it returns `report-only`.
13
+ * `--yes` mode skips.
14
+ */
15
+
16
+ import type {
17
+ DoctorContext,
18
+ DoctorRule,
19
+ Finding,
20
+ RepairPlan,
21
+ RepairResult,
22
+ } from '../types.ts';
23
+
24
+ const RULE_ID = 'slug-collision';
25
+
26
+ interface CollisionGroup {
27
+ slug: string;
28
+ /** Calendar-entry ids sharing this slug, in iteration order. */
29
+ entryIds: string[];
30
+ }
31
+
32
+ function findCollisions(ctx: DoctorContext): CollisionGroup[] {
33
+ const bySlug = new Map<string, string[]>();
34
+ for (const e of ctx.calendar.entries) {
35
+ if (!e.slug) continue;
36
+ const list = bySlug.get(e.slug);
37
+ if (list) list.push(e.id ?? '');
38
+ else bySlug.set(e.slug, [e.id ?? '']);
39
+ }
40
+ const out: CollisionGroup[] = [];
41
+ for (const [slug, ids] of bySlug) {
42
+ if (ids.length > 1) out.push({ slug, entryIds: ids });
43
+ }
44
+ return out;
45
+ }
46
+
47
+ const rule: DoctorRule = {
48
+ id: RULE_ID,
49
+ label: 'Duplicate slugs in the calendar',
50
+
51
+ async audit(ctx: DoctorContext): Promise<Finding[]> {
52
+ return findCollisions(ctx).map((g) => ({
53
+ ruleId: RULE_ID,
54
+ site: ctx.site,
55
+ severity: 'error',
56
+ message: `slug "${g.slug}" is shared by ${g.entryIds.length} calendar entries`,
57
+ details: { slug: g.slug, entryIds: g.entryIds },
58
+ }));
59
+ },
60
+
61
+ async plan(_ctx: DoctorContext, finding: Finding): Promise<RepairPlan> {
62
+ return {
63
+ kind: 'report-only',
64
+ finding,
65
+ reason:
66
+ 'pick which entry owns the slug and rename the others via `deskwork rename-slug` ' +
67
+ '(or hand-edit the calendar). Doctor refuses to choose automatically — slug is ' +
68
+ 'host-public-URL, an editorial decision.',
69
+ };
70
+ },
71
+
72
+ async apply(_ctx: DoctorContext, plan: RepairPlan): Promise<RepairResult> {
73
+ return {
74
+ finding: plan.finding,
75
+ applied: false,
76
+ message: 'slug-collision has no automatic repair (operator must rename)',
77
+ skipReason: 'editorial-decision',
78
+ };
79
+ },
80
+ };
81
+
82
+ export default rule;