canopycms 0.0.64 → 0.0.66-int.81

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.
@@ -289,6 +289,45 @@ export declare class ContentStore {
289
289
  */
290
290
  getSchemaItems(): IterableIterator<FlatSchemaItem>;
291
291
  private assertSchemaItem;
292
+ /**
293
+ * Is this path a COLLECTION schema item?
294
+ *
295
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
296
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
297
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
298
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
299
+ * first-wins and would not.
300
+ *
301
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
302
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
303
+ * would reject something `buildPaths` accepts.
304
+ *
305
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
306
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
307
+ */
308
+ isCollectionPath(collectionPath: LogicalPath): boolean;
309
+ /**
310
+ * Does this collection declare `entryTypeName` in its `entries` config?
311
+ *
312
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
313
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
314
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
315
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
316
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
317
+ *
318
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
319
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
320
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
321
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
322
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
323
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
324
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
325
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
326
+ *
327
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
328
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
329
+ */
330
+ declaresEntryType(collectionPath: LogicalPath, entryTypeName: string): boolean;
292
331
  private assertCollection;
293
332
  /**
294
333
  * Lock key for an existing entry, addressed by its permanent content ID
@@ -391,6 +391,54 @@ export class ContentStore {
391
391
  }
392
392
  return item;
393
393
  }
394
+ /**
395
+ * Is this path a COLLECTION schema item?
396
+ *
397
+ * The non-throwing form of `assertCollection`, reading the same `schemaIndex` -- which is the
398
+ * point. A caller that gates on this cannot disagree with what `buildPaths` will then do: the
399
+ * Map is last-wins, so where a subcollection's path collides with a parent's entry-type name
400
+ * both this and `buildPaths` see the collection. A `find` over the flat schema LIST is
401
+ * first-wins and would not.
402
+ *
403
+ * Type-only, deliberately -- `resolvePath` additionally requires `entries`, but a collection
404
+ * with subcollections and no entries of its own is legal, and mirroring that stricter test here
405
+ * would reject something `buildPaths` accepts.
406
+ *
407
+ * Exists for `readByUrlPath`'s URL-addressability gate; see `ReadContentInput`'s
408
+ * `urlAddressableOnly` and the note on `buildPaths`' entry-type branch below.
409
+ */
410
+ isCollectionPath(collectionPath) {
411
+ return this.schemaIndex.get(normalizeFilesystemPath(collectionPath))?.type === 'collection';
412
+ }
413
+ /**
414
+ * Does this collection declare `entryTypeName` in its `entries` config?
415
+ *
416
+ * Mirrors `parseTypedFilename(filename, collection.entries)`, which is how `listEntries` decides
417
+ * whether a file on disk is one of the collection's entries at all. `buildPaths`' own directory
418
+ * scan deliberately does NOT check this -- it matches on slug alone, so that an entry whose type
419
+ * was renamed out of the schema stays findable and therefore still editable, renameable and
420
+ * deletable. Only URL resolution consults this, so what enumeration hides is not served.
421
+ *
422
+ * Returns FALSE when the collection declares no `entries` at all, which is stricter than
423
+ * `parseTypedFilename`'s own `if (entryTypes && ...)` guard and deliberately so: the enumerating
424
+ * surface is not `parseTypedFilename`, it is `listCollectionEntries`, and that returns `[]`
425
+ * outright for a collection with no `entries`. A collections-only container therefore publishes
426
+ * nothing, so a URL read that resolved a file sitting in one would be answering where nothing is
427
+ * advertised -- the exact disagreement this predicate exists to close. Such a file cannot have
428
+ * been created by the CMS (there is no entry type to create it as); it arrived by hand, by merge
429
+ * or by retrofit, and it is invisible to the sitemap and to static params either way.
430
+ *
431
+ * Returns true for a path that is not a collection at all, because that is rule 1's question,
432
+ * not this one's -- and under `urlAddressableOnly` rule 1 has already rejected it.
433
+ */
434
+ declaresEntryType(collectionPath, entryTypeName) {
435
+ const item = this.schemaIndex.get(normalizeFilesystemPath(collectionPath));
436
+ if (item?.type !== 'collection')
437
+ return true;
438
+ if (!item.entries)
439
+ return false;
440
+ return item.entries.some((e) => e.name === entryTypeName);
441
+ }
394
442
  assertCollection(collectionPath) {
395
443
  const item = this.assertSchemaItem(collectionPath);
396
444
  if (item.type !== 'collection') {
@@ -499,9 +547,20 @@ export class ContentStore {
499
547
  const rootWithSep = this.root.endsWith(path.sep) ? this.root : `${this.root}${path.sep}`;
500
548
  // Entry-type items: delegate to their parent collection.
501
549
  // Uses the same {type}.{slug}.{id}.{ext} pattern as all entries.
502
- // NOTE: The API layer always resolves paths via resolvePath(), which returns
503
- // the parent collection directly, so this branch may only fire on direct
504
- // ContentStore usage (e.g., store.read('content/home', '')).
550
+ //
551
+ // This branch is for DIRECT ContentStore usage -- store.read('content/home', ''), and the
552
+ // read({ entryPath: 'content/home' }) API built on it, where the slug defaults to the entry
553
+ // type's own name. The API layer resolves paths via resolvePath(), which returns the parent
554
+ // collection directly and so never lands here.
555
+ //
556
+ // That used to be an observation, and it was WRONG: readByUrlPath reached this branch too,
557
+ // because `resolveUrlPathCandidates` happily produces `content/<typeName>` for the URL
558
+ // `/<typeName>` and the delegation below then answered it with the parent collection's index
559
+ // entry -- a URL no forward surface publishes. It is now enforced rather than assumed:
560
+ // readByUrlPath requires every candidate's entryPath to be a collection (see
561
+ // `isCollectionPath` and `ReadContentInput.urlAddressableOnly`). Narrowing the delegation
562
+ // ITSELF was the wrong fix -- write()/renameEntry()/delete() resolve through here as well,
563
+ // and the by-URL rule has no business constraining them.
505
564
  if (schemaItem.type === 'entry-type') {
506
565
  const parentPath = schemaItem.parentPath || '';
507
566
  const parentCollection = this.schemaIndex.get(parentPath);
package/dist/context.d.ts CHANGED
@@ -76,12 +76,22 @@ export interface CanopyBuildContext {
76
76
  * Read content by URL path, resolving the collection/entry split automatically.
77
77
  *
78
78
  * Tries direct entry match first (last segment = slug, rest = collection path),
79
- * then falls back to index entry (full path = collection, slug = 'index').
79
+ * then falls back to index entry (full path = collection, slug = 'index'). Both attempts
80
+ * require the collection part to be a real collection — see below.
80
81
  * Root path '/' resolves to the content root's index entry.
81
82
  *
82
- * The direct-entry attempt is SKIPPED when the last segment is an index slug (any case), so
83
- * `/x/index` returns null rather than a second URL for the entry that already answers at `/x`.
84
- * A collection literally named `index` is unaffected — the fallback resolves it.
83
+ * Resolves ONLY what `listEntries` publishes — `readByUrlPath(item.urlPath)` reaches the entry,
84
+ * and no other spelling does. Three shapes that used to resolve are therefore null now:
85
+ * `/x/index` (the literal spelling of an index entry's collapsed URL, in any case),
86
+ * `/<collection>/<entryTypeName>` and `/<collection>/<entryTypeName>/<slug>` (an entry-type
87
+ * path is not a collection, so it is not part of any published URL), and an entry whose
88
+ * on-disk type token its collection does not declare (`listEntries` skips those too). A
89
+ * collection literally named `index` is unaffected — the index fallback resolves it.
90
+ *
91
+ * `read({ entryPath: 'content/home' })` is deliberately NOT narrowed: addressing an entry
92
+ * structurally, by its schema path, is a different question from addressing it by its
93
+ * published URL, and it is the only way to reach a singleton without knowing its slug.
94
+ *
85
95
  * Returns null if no content matches the path — including collection URLs that have no
86
96
  * index entry (use buildContentTree for those) and non-entry/invalid paths such as
87
97
  * `/favicon.ico` or Next internals (the slug validator rejects them, treated as a miss).
package/dist/context.js CHANGED
@@ -60,8 +60,12 @@ export function createCanopyContext(options) {
60
60
  const user = await getUser();
61
61
  // Create base content reader
62
62
  const baseReader = createContentReader({ services });
63
- // Wrap reader to inject user automatically, validating strings → branded types at this boundary
64
- const read = async (input) => {
63
+ // Wrap reader to inject user automatically, validating strings → branded types at this
64
+ // boundary. `extra` carries options that are NOT part of the public `read` surface -- today
65
+ // just readByUrlPath's URL-addressability gate. Kept as a separate inner function rather than
66
+ // an optional second parameter on the `CanopyContext['read']`-typed closure so the two call
67
+ // sites stay visible and nobody widens the public API by accident.
68
+ const readWithOptions = async (input, extra) => {
65
69
  const entryPath = createLogicalPath(input.entryPath);
66
70
  let slug;
67
71
  if (input.slug) {
@@ -77,9 +81,11 @@ export function createCanopyContext(options) {
77
81
  branch: input.branch,
78
82
  user,
79
83
  resolveReferences: input.resolveReferences ?? true,
84
+ ...extra,
80
85
  };
81
86
  return baseReader.read(readInput);
82
87
  };
88
+ const read = (input) => readWithOptions(input);
83
89
  const readByUrlPath = async (urlPath, options) => {
84
90
  const contentRoot = services.config.contentRoot || 'content';
85
91
  const candidates = resolveUrlPathCandidates(urlPath, contentRoot);
@@ -93,12 +99,17 @@ export function createCanopyContext(options) {
93
99
  if (!parseSlug(candidate.slug).ok)
94
100
  continue;
95
101
  try {
96
- return await read({
102
+ return await readWithOptions({
97
103
  entryPath: candidate.entryPath,
98
104
  slug: candidate.slug,
99
105
  branch,
100
106
  resolveReferences,
101
- });
107
+ },
108
+ // This is a read BY PUBLISHED URL, so it must accept only what listEntries publishes.
109
+ // See ReadContentInput.urlAddressableOnly for the two rules and why they live in the
110
+ // reader (which holds the branch-correct schema) rather than in the candidate builder
111
+ // (which is pure and schema-free by design).
112
+ { urlAddressableOnly: true });
102
113
  }
103
114
  catch (err) {
104
115
  // Swallow "not found" errors from trying candidate paths, and FORBIDDEN (a denied
@@ -15,6 +15,14 @@
15
15
  *
16
16
  * All logic lives here in the core package; framework adapters just call startDevContentWatcher() once
17
17
  * at dev startup. See packages/canopycms-next/src/context-wrapper.ts for the Next wiring.
18
+ *
19
+ * ## Divergence is a CONDITION, not an event
20
+ *
21
+ * It stays true until someone runs `sync push`, so the two things this module has to get right are
22
+ * (a) not re-printing the same condition into a scrolling request log, and (b) making the one printing
23
+ * of it hard to scroll past. Both are handled below by `reportOnce` + the gutter formatting; see
24
+ * `.claude/future-tasks/dev-divergence-in-app-surface.md` for the in-app surface that would replace
25
+ * the terminal as the primary home for this.
18
26
  */
19
27
  import type { CanopyServices } from './services.js';
20
28
  import type { DevContentSyncMode } from './config/types.js';
@@ -28,6 +36,8 @@ export interface StartDevContentWatcherOptions {
28
36
  /** Warning sink. Defaults to console.warn. */
29
37
  warn?: (message: string) => void;
30
38
  }
39
+ /** Test-only: dispose every armed watcher and drop all cross-module dedupe state. */
40
+ export declare function resetDevContentWatchersForTests(): void;
31
41
  /**
32
42
  * Start watching the working-tree content directory for divergence from the served dev branch clone.
33
43
  * Returns a disposer that stops the watcher. A no-op (returns immediately) when mode is 'off', when
@@ -15,35 +15,98 @@
15
15
  *
16
16
  * All logic lives here in the core package; framework adapters just call startDevContentWatcher() once
17
17
  * at dev startup. See packages/canopycms-next/src/context-wrapper.ts for the Next wiring.
18
+ *
19
+ * ## Divergence is a CONDITION, not an event
20
+ *
21
+ * It stays true until someone runs `sync push`, so the two things this module has to get right are
22
+ * (a) not re-printing the same condition into a scrolling request log, and (b) making the one printing
23
+ * of it hard to scroll past. Both are handled below by `reportOnce` + the gutter formatting; see
24
+ * `.claude/future-tasks/dev-divergence-in-app-surface.md` for the in-app surface that would replace
25
+ * the terminal as the primary home for this.
18
26
  */
19
27
  import fsSync from 'node:fs';
20
28
  import path from 'node:path';
21
29
  import chokidar from 'chokidar';
30
+ import pc from 'picocolors';
22
31
  import { operatingStrategy } from './operating-mode/index.js';
23
32
  import { getErrorMessage } from './utils/error.js';
24
33
  import { diffContentTrees, isContentTreeDiffEmpty } from './sync-core.js';
25
- const MAX_LISTED_FILES = 10;
26
- // Dedupe watchers by working-tree content dir so HMR re-evaluating the adapter module (which discards
27
- // the disposer) doesn't accumulate watchers. This module lives in canopycms/server, so its state
28
- // survives app-side HMR. Each new start disposes any prior watcher for the same dir.
29
- const activeWatchers = new Map();
34
+ /**
35
+ * Per-category cap on listed files. Deliberately small: the block is a prompt to run `sync push`,
36
+ * not a diff viewer, and a 30-line wall of paths is exactly as easy to scroll past as a 1-line
37
+ * warning. Each list carries its true count, so nothing is hidden -- only elided.
38
+ */
39
+ const MAX_LISTED_FILES = 5;
40
+ /**
41
+ * The registry lives on `globalThis`, NOT in module scope.
42
+ *
43
+ * Next's dev server compiles the server graph per route bundle and evaluates each copy in its own
44
+ * module scope, so a module-level `Map` is re-created empty per bundle -- every copy then believes it
45
+ * is the first watcher, arms its own, and re-prints the startup warning. (The duplicated
46
+ * "CanopyCMS dev-auth: Auto-configured ..." line in a dev log is the same effect on a different
47
+ * module-level latch.) A `globalThis` property is the one thing shared across those evaluations.
48
+ */
49
+ const REGISTRY_KEY = '__canopycmsDevContentWatchers__';
50
+ function watcherRegistry() {
51
+ const host = globalThis;
52
+ const existing = host[REGISTRY_KEY];
53
+ if (existing)
54
+ return existing;
55
+ const created = new Map();
56
+ host[REGISTRY_KEY] = created;
57
+ return created;
58
+ }
59
+ /** Test-only: dispose every armed watcher and drop all cross-module dedupe state. */
60
+ export function resetDevContentWatchersForTests() {
61
+ const registry = watcherRegistry();
62
+ for (const state of registry.values())
63
+ state.dispose?.();
64
+ registry.clear();
65
+ }
66
+ /**
67
+ * One body line of the notice block. The left gutter (rather than a full box) is deliberate: content
68
+ * paths are long and unpredictable, and a right border would force either wrapping math or truncation
69
+ * of the very filenames the block exists to name.
70
+ */
71
+ function body(line) {
72
+ const gutter = pc.yellow('|');
73
+ return line ? `${gutter} ${line}` : gutter;
74
+ }
30
75
  function formatList(label, files) {
31
76
  if (files.length === 0)
32
- return null;
33
- const shown = files.slice(0, MAX_LISTED_FILES).join(', ');
34
- const extra = files.length > MAX_LISTED_FILES ? `, …(+${files.length - MAX_LISTED_FILES} more)` : '';
35
- return ` ${label}: ${shown}${extra}`;
77
+ return [];
78
+ const shown = files.slice(0, MAX_LISTED_FILES);
79
+ const lines = [body(pc.bold(`${label} (${files.length})`)), ...shown.map((f) => body(` ${f}`))];
80
+ if (files.length > shown.length) {
81
+ lines.push(body(pc.dim(` +${files.length - shown.length} more`)));
82
+ }
83
+ return lines;
36
84
  }
37
85
  function formatDivergenceWarning(branch, diff) {
38
- const lines = [
39
- `CanopyCMS: working-tree content has diverged from the dev branch clone "${branch}" — the dev ` +
40
- 'server is serving stale content. Run `npx canopycms sync push` to update it ' +
41
- "(set dev.contentSync: 'off' to silence this).",
42
- formatList('changed', diff.changed),
43
- formatList('only in working tree', diff.added),
44
- formatList('only in branch clone', diff.removed),
45
- ].filter((line) => line !== null);
46
- return lines.join('\n');
86
+ const total = diff.changed.length + diff.added.length + diff.removed.length;
87
+ // Blank lines top and bottom: the block's job is to be findable in a scrolling request log, and
88
+ // whitespace separation does more for that than any amount of in-block decoration.
89
+ return [
90
+ '',
91
+ `${pc.bgYellow(pc.black(' CanopyCMS '))} ${pc.bold(pc.yellow('working-tree content has diverged'))}`,
92
+ body(`The dev server is serving ${pc.bold('stale content')}: ${total} file(s) differ from the`),
93
+ body(`dev branch clone "${branch}".`),
94
+ body(''),
95
+ ...formatList('changed', diff.changed),
96
+ ...formatList('only in working tree', diff.added),
97
+ ...formatList('only in branch clone', diff.removed),
98
+ body(''),
99
+ body(`Fix: ${pc.bold('npx canopycms sync push')}`),
100
+ body(pc.dim("Silence: set dev.contentSync: 'off' in your Canopy config")),
101
+ '',
102
+ ].join('\n');
103
+ }
104
+ function formatResolvedNotice(branch) {
105
+ return [
106
+ '',
107
+ `${pc.bgGreen(pc.black(' CanopyCMS '))} ${pc.green(`working-tree content is back in sync with "${branch}"`)}`,
108
+ '',
109
+ ].join('\n');
47
110
  }
48
111
  /**
49
112
  * Start watching the working-tree content directory for divergence from the served dev branch clone.
@@ -73,6 +136,27 @@ export function startDevContentWatcher(services, options = {}) {
73
136
  services.config.defaultActiveBranch ??
74
137
  services.config.defaultBaseBranch ??
75
138
  'main';
139
+ const registry = watcherRegistry();
140
+ let state = registry.get(workingTreeContentDir);
141
+ if (!state) {
142
+ state = { dispose: null, lastMessage: null, reportedDivergence: false };
143
+ registry.set(workingTreeContentDir, state);
144
+ }
145
+ const watcherState = state;
146
+ // Dispose any prior watcher for this dir (HMR re-start) before creating a new one. The registry
147
+ // ENTRY deliberately survives, carrying lastMessage/reportedDivergence into the new watcher.
148
+ watcherState.dispose?.();
149
+ /**
150
+ * Emit `message` unless it is verbatim what was emitted last for this directory. Covers the
151
+ * divergence block, the resolved notice and the error paths alike: in every case a repeat means
152
+ * "the condition is unchanged", which the reader already knows.
153
+ */
154
+ const reportOnce = (message) => {
155
+ if (watcherState.lastMessage === message)
156
+ return;
157
+ watcherState.lastMessage = message;
158
+ warn(message);
159
+ };
76
160
  let running = false;
77
161
  let pending = false;
78
162
  const check = async () => {
@@ -84,16 +168,26 @@ export function startDevContentWatcher(services, options = {}) {
84
168
  try {
85
169
  const branch = resolveBranch();
86
170
  const branchContentDir = path.join(strategy.getContentBranchRoot(branch, sourceRoot), contentRoot);
87
- // No branch clone yet (e.g. before the first editor/dev request created it) → nothing to compare.
171
+ // No branch clone yet (e.g. before the first editor/dev request created it) -> nothing to
172
+ // compare. Deliberately leaves reportedDivergence alone: "cannot tell" is not "resolved".
88
173
  if (!fsSync.existsSync(branchContentDir))
89
174
  return;
90
175
  const diff = await diffContentTrees(workingTreeContentDir, branchContentDir);
91
- if (isContentTreeDiffEmpty(diff))
176
+ if (isContentTreeDiffEmpty(diff)) {
177
+ // Close the loop: a condition that was announced needs its retraction announced too, or the
178
+ // reader is left believing the dev server is still serving stale content.
179
+ if (watcherState.reportedDivergence) {
180
+ watcherState.reportedDivergence = false;
181
+ reportOnce(formatResolvedNotice(branch));
182
+ }
92
183
  return;
93
- warn(formatDivergenceWarning(branch, diff));
184
+ }
185
+ // Set BEFORE reporting so a deduped repeat still leaves the condition marked as outstanding.
186
+ watcherState.reportedDivergence = true;
187
+ reportOnce(formatDivergenceWarning(branch, diff));
94
188
  }
95
189
  catch (err) {
96
- warn(`CanopyCMS: dev content-sync check failed: ${getErrorMessage(err)}`);
190
+ reportOnce(`CanopyCMS: dev content-sync check failed: ${getErrorMessage(err)}`);
97
191
  }
98
192
  finally {
99
193
  running = false;
@@ -103,22 +197,19 @@ export function startDevContentWatcher(services, options = {}) {
103
197
  }
104
198
  }
105
199
  };
106
- // Dispose any prior watcher for this dir (HMR re-start) before creating a new one.
107
- activeWatchers.get(workingTreeContentDir)?.();
108
200
  const watcher = chokidar.watch(workingTreeContentDir, { ignoreInitial: true });
109
201
  watcher.on('add', () => void check());
110
202
  watcher.on('change', () => void check());
111
203
  watcher.on('unlink', () => void check());
112
204
  // Handle watcher errors (e.g. inotify ENOSPC/EMFILE) so an unhandled 'error' event can't crash dev.
113
- watcher.on('error', (err) => warn(`CanopyCMS: dev content-sync watcher error: ${getErrorMessage(err)}`));
205
+ watcher.on('error', (err) => reportOnce(`CanopyCMS: dev content-sync watcher error: ${getErrorMessage(err)}`));
114
206
  // Initial divergence check at startup.
115
207
  void check();
116
208
  const dispose = () => {
117
209
  void watcher.close();
118
- if (activeWatchers.get(workingTreeContentDir) === dispose) {
119
- activeWatchers.delete(workingTreeContentDir);
120
- }
210
+ if (watcherState.dispose === dispose)
211
+ watcherState.dispose = null;
121
212
  };
122
- activeWatchers.set(workingTreeContentDir, dispose);
213
+ watcherState.dispose = dispose;
123
214
  return dispose;
124
215
  }
@@ -339,7 +339,16 @@ export async function recoverOrphanedTasks(taskDir, maxAgeMs = 5 * 60_000, logge
339
339
  export async function cleanupOldTasks(taskDir, maxAgeMs = 30 * 24 * 60 * 60_000, logger = nullLogger) {
340
340
  const now = Date.now();
341
341
  let cleaned = 0;
342
- for (const subdir of ['completed', 'failed']) {
342
+ // `corrupt` included: unparseable task files are quarantined there by
343
+ // dequeue and orphan recovery and surfaced in admin listing, but the
344
+ // retention sweep never covered the directory, so it grew forever with
345
+ // deletion available only as a manual per-file admin action. Any recurring
346
+ // producer of malformed task JSON -- a partial write surviving a crash, a
347
+ // bad deploy writing schema-drifted tasks for a week -- accumulated files no
348
+ // automated path removed. Same stamp-based retention as the other two: a
349
+ // quarantined file older than the window has long since been triaged or
350
+ // forgotten.
351
+ for (const subdir of ['completed', 'failed', 'corrupt']) {
343
352
  const dir = path.join(taskDir, subdir);
344
353
  let files;
345
354
  try {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The one-URL-per-entry invariant, as a reusable probe + report.
3
+ *
4
+ * `listEntries` assigns every entry exactly one `urlPath` and documents the round trip through
5
+ * `readByUrlPath` as safe. The reverse direction has repeatedly been the looser of the two: it
6
+ * has answered at URLs no forward surface emits, and those extra URLs were found one at a time,
7
+ * a release apart, by adopters (see
8
+ * `.claude/future-tasks/resolved/url-resolver-index-entry-extra-url.md`, then
9
+ * `resolved/readbyurlpath-entry-type-candidate-phantom-url.md`). This module exists so the whole
10
+ * invariant is asserted at once instead: enumerate, then probe every ADJACENT URL the resolver
11
+ * would try and require it to be a miss.
12
+ *
13
+ * Deliberately free of `vitest` -- this is a plain `src` module in the shape of
14
+ * `operating-mode/deployment-name-fixtures.ts`, so more than one test file can import it without
15
+ * dragging that file's `vi.mock` calls and `describe` blocks along. It therefore ASSERTS NOTHING:
16
+ * it returns a report and the caller does the expecting, which also puts the offending URLs in
17
+ * the assertion message rather than behind a boolean.
18
+ *
19
+ * Scope of the invariant it checks: entries written in the `{type}.{slug}.{id}.{ext}` grammar
20
+ * with a type their collection declares -- i.e. exactly the set `listEntries` can see. A legacy
21
+ * untyped file (`overview.json`) is invisible to enumeration and is deliberately NOT probed here;
22
+ * see `.claude/future-tasks/legacy-untyped-files-url-addressable.md`.
23
+ */
24
+ import { type RootCollectionConfig } from './config/index.js';
25
+ import type { ListEntriesItem } from './content-listing.js';
26
+ import type { CanopyContext } from './context.js';
27
+ import { type DuplicateUrlPath } from './static/index.js';
28
+ /**
29
+ * Every URL adjacent to the published set that the resolver would actually attempt.
30
+ *
31
+ * Three families, each one a shape that HAS resolved at some point in this package's history:
32
+ *
33
+ * 1. `/<collection>/<entryTypeName>` -- `resolveUrlPathCandidates`' index-fallback candidate lands
34
+ * on a registered entry-TYPE schema item, which `ContentStore.buildPaths` delegates to the
35
+ * parent collection with slug `index`, resolving that collection's index entry.
36
+ * 2. `/<collection>/<entryTypeName>/<slug>` -- the same delegation reached through the
37
+ * DIRECT-entry candidate instead, resolving the collection's entry `<slug>`. Wider than family
38
+ * 1: it needs no index entry at all, so it applies to every listed entry.
39
+ * 3. `/<publishedUrl>/index`, and its case variants -- an index entry answering at the literal
40
+ * spelling its collapsed URL replaced.
41
+ *
42
+ * Entry-type names are appended VERBATIM, after `computeEntryUrl` has lowercased the collection
43
+ * part. That asymmetry is real, not sloppiness: `flattenSchema` puts `entryType.name` into the
44
+ * logical path unchanged and `normalizeFilesystemPath` does not lowercase, so only the declared
45
+ * spelling can reach the entry-type schema item at all. A lowercased probe would miss the schema
46
+ * item, return null for the wrong reason, and pass vacuously.
47
+ */
48
+ export declare const buildProbeUrls: (schema: RootCollectionConfig, items: readonly ListEntriesItem[], contentRoot?: string) => string[];
49
+ export interface UrlExclusivityReport {
50
+ /** Every `urlPath` the listing published, sorted. */
51
+ published: string[];
52
+ /** URLs claimed by more than one entry. A precondition failure, not a resolver finding. */
53
+ duplicates: DuplicateUrlPath[];
54
+ /** Published URLs `readByUrlPath` did NOT resolve -- the round trip broken going forward. */
55
+ unresolved: string[];
56
+ /** Published URLs that resolved to some OTHER entry than the one that published them. */
57
+ mismatched: Array<{
58
+ urlPath: string;
59
+ expectedEntryId?: string;
60
+ actualEntryId?: string;
61
+ }>;
62
+ /** Every adjacent URL probed, sorted. */
63
+ probes: string[];
64
+ /** Probes that resolved despite not being published -- the phantom URLs. Must be empty. */
65
+ phantoms: string[];
66
+ }
67
+ /**
68
+ * Enumerate, round-trip, then probe. See `UrlExclusivityReport` for what each field means and
69
+ * `buildProbeUrls` for which URLs are probed.
70
+ *
71
+ * A probe whose LOWERCASED form is published is skipped rather than asserted on: a collection
72
+ * literally named `index` publishes `/docs/index`, which family 3 also generates, and whether
73
+ * `/docs/Index` should resolve is a separate, pre-existing question about collection-path case
74
+ * sensitivity that this invariant does not speak to.
75
+ */
76
+ export declare const collectUrlExclusivityReport: (ctx: Pick<CanopyContext, "listEntries" | "readByUrlPath">, schema: RootCollectionConfig, contentRoot?: string) => Promise<UrlExclusivityReport>;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The one-URL-per-entry invariant, as a reusable probe + report.
3
+ *
4
+ * `listEntries` assigns every entry exactly one `urlPath` and documents the round trip through
5
+ * `readByUrlPath` as safe. The reverse direction has repeatedly been the looser of the two: it
6
+ * has answered at URLs no forward surface emits, and those extra URLs were found one at a time,
7
+ * a release apart, by adopters (see
8
+ * `.claude/future-tasks/resolved/url-resolver-index-entry-extra-url.md`, then
9
+ * `resolved/readbyurlpath-entry-type-candidate-phantom-url.md`). This module exists so the whole
10
+ * invariant is asserted at once instead: enumerate, then probe every ADJACENT URL the resolver
11
+ * would try and require it to be a miss.
12
+ *
13
+ * Deliberately free of `vitest` -- this is a plain `src` module in the shape of
14
+ * `operating-mode/deployment-name-fixtures.ts`, so more than one test file can import it without
15
+ * dragging that file's `vi.mock` calls and `describe` blocks along. It therefore ASSERTS NOTHING:
16
+ * it returns a report and the caller does the expecting, which also puts the offending URLs in
17
+ * the assertion message rather than behind a boolean.
18
+ *
19
+ * Scope of the invariant it checks: entries written in the `{type}.{slug}.{id}.{ext}` grammar
20
+ * with a type their collection declares -- i.e. exactly the set `listEntries` can see. A legacy
21
+ * untyped file (`overview.json`) is invisible to enumeration and is deliberately NOT probed here;
22
+ * see `.claude/future-tasks/legacy-untyped-files-url-addressable.md`.
23
+ */
24
+ import { flattenSchema } from './config/index.js';
25
+ import { findDuplicateUrlPaths } from './static/index.js';
26
+ import { computeEntryUrl } from './utils/entry-url.js';
27
+ /** Append raw segments to a URL base, without normalizing their case (see buildProbeUrls). */
28
+ const joinUrl = (base, ...segments) => (base === '/' ? '' : base) + segments.map((s) => `/${s}`).join('');
29
+ /**
30
+ * Every URL adjacent to the published set that the resolver would actually attempt.
31
+ *
32
+ * Three families, each one a shape that HAS resolved at some point in this package's history:
33
+ *
34
+ * 1. `/<collection>/<entryTypeName>` -- `resolveUrlPathCandidates`' index-fallback candidate lands
35
+ * on a registered entry-TYPE schema item, which `ContentStore.buildPaths` delegates to the
36
+ * parent collection with slug `index`, resolving that collection's index entry.
37
+ * 2. `/<collection>/<entryTypeName>/<slug>` -- the same delegation reached through the
38
+ * DIRECT-entry candidate instead, resolving the collection's entry `<slug>`. Wider than family
39
+ * 1: it needs no index entry at all, so it applies to every listed entry.
40
+ * 3. `/<publishedUrl>/index`, and its case variants -- an index entry answering at the literal
41
+ * spelling its collapsed URL replaced.
42
+ *
43
+ * Entry-type names are appended VERBATIM, after `computeEntryUrl` has lowercased the collection
44
+ * part. That asymmetry is real, not sloppiness: `flattenSchema` puts `entryType.name` into the
45
+ * logical path unchanged and `normalizeFilesystemPath` does not lowercase, so only the declared
46
+ * spelling can reach the entry-type schema item at all. A lowercased probe would miss the schema
47
+ * item, return null for the wrong reason, and pass vacuously.
48
+ */
49
+ export const buildProbeUrls = (schema, items, contentRoot = 'content') => {
50
+ const collections = flattenSchema(schema, contentRoot).filter((i) => i.type === 'collection');
51
+ const probes = new Set();
52
+ for (const collection of collections) {
53
+ const base = computeEntryUrl(collection.logicalPath, '', contentRoot);
54
+ for (const entryType of collection.entries ?? []) {
55
+ // Family 1.
56
+ probes.add(joinUrl(base, entryType.name));
57
+ // Family 2 -- every listed entry of this collection, under every type name the collection
58
+ // declares (not just the entry's OWN type: the directory scan `buildPaths` runs matches on
59
+ // slug alone, so any declared name reaches any entry).
60
+ for (const item of items) {
61
+ if (String(item.collectionPath) !== String(collection.logicalPath))
62
+ continue;
63
+ probes.add(joinUrl(base, entryType.name, item.slug));
64
+ }
65
+ }
66
+ }
67
+ // Family 3.
68
+ for (const item of items) {
69
+ for (const spelling of ['index', 'Index', 'INDEX']) {
70
+ probes.add(joinUrl(item.urlPath, spelling));
71
+ }
72
+ }
73
+ return [...probes];
74
+ };
75
+ /**
76
+ * Enumerate, round-trip, then probe. See `UrlExclusivityReport` for what each field means and
77
+ * `buildProbeUrls` for which URLs are probed.
78
+ *
79
+ * A probe whose LOWERCASED form is published is skipped rather than asserted on: a collection
80
+ * literally named `index` publishes `/docs/index`, which family 3 also generates, and whether
81
+ * `/docs/Index` should resolve is a separate, pre-existing question about collection-path case
82
+ * sensitivity that this invariant does not speak to.
83
+ */
84
+ export const collectUrlExclusivityReport = async (ctx, schema, contentRoot = 'content') => {
85
+ const items = await ctx.listEntries();
86
+ const unresolved = [];
87
+ const mismatched = [];
88
+ for (const item of items) {
89
+ const result = await ctx.readByUrlPath(item.urlPath);
90
+ if (!result) {
91
+ unresolved.push(item.urlPath);
92
+ continue;
93
+ }
94
+ if (result.meta.entryId !== item.entryId) {
95
+ mismatched.push({
96
+ urlPath: item.urlPath,
97
+ expectedEntryId: item.entryId,
98
+ actualEntryId: result.meta.entryId,
99
+ });
100
+ }
101
+ }
102
+ const publishedLower = new Set(items.map((i) => i.urlPath.toLowerCase()));
103
+ const probes = buildProbeUrls(schema, items, contentRoot);
104
+ const phantoms = [];
105
+ for (const probe of probes) {
106
+ if (publishedLower.has(probe.toLowerCase()))
107
+ continue;
108
+ if (await ctx.readByUrlPath(probe))
109
+ phantoms.push(probe);
110
+ }
111
+ return {
112
+ published: items.map((i) => i.urlPath).sort(),
113
+ duplicates: findDuplicateUrlPaths(items),
114
+ unresolved: unresolved.sort(),
115
+ mismatched,
116
+ probes: [...probes].sort(),
117
+ phantoms: phantoms.sort(),
118
+ };
119
+ };