@hiveku-apps/sync 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Plain-language version names: a port of the builder's naming module.
3
+ *
4
+ * Source of truth: hiveku_builder src/lib/vcs/version-naming.ts, with the two
5
+ * pure modules it depends on (src/lib/deployment/file-to-route.ts and
6
+ * src/lib/vcs/customer-visible-path.ts, which reads
7
+ * src/lib/builder/server-managed-files.ts and
8
+ * src/lib/builder/project-memory-path-policy.ts). The builder, the Python
9
+ * agent fallback (version_name.py) and the VS Code extension (versionName.ts)
10
+ * are tested against one table, test/fixtures/version-naming-cases.json (a
11
+ * copy of src/lib/vcs/__fixtures__/version-naming-cases.json); this port runs
12
+ * every row of it. Change a rule there first, copy the table, then re-port.
13
+ *
14
+ * What hiveku-sync uses it for:
15
+ * - describeChanges: the name of the version a push saves when the person
16
+ * gave none ("Edited the Home page", never a file path);
17
+ * - isPlainVersionName: the check on a name typed with -m. The builder keeps
18
+ * a hiveku-sync name exactly as sent (source sync_cli), so the CLI checks
19
+ * it itself before anything is written.
20
+ *
21
+ * Every sort compares plain character codes (never locale order), as the
22
+ * builder does, so the ports agree.
23
+ */
24
+
25
+ // ── server-managed-files.ts ──────────────────────────────────────────────────
26
+
27
+ const SERVER_MANAGED_PATHS = new Set([
28
+ 'soul.md',
29
+ 'connected_tools.md',
30
+ '.mcp.json',
31
+ '.claude/settings.json',
32
+ '.claude/commands/generate-image.md',
33
+ '.claude/commands/scrape.md',
34
+ '.claude/commands/search.md',
35
+ ]);
36
+ const SERVER_MANAGED_PREFIXES = ['.claude/skills/', '.claude/rules/', 'data/marketing/'];
37
+
38
+ /** Files the server writes into every project (never the customer's work). */
39
+ export function isServerManagedFile(filePath) {
40
+ if (SERVER_MANAGED_PATHS.has(filePath)) return true;
41
+ return SERVER_MANAGED_PREFIXES.some((prefix) => filePath.startsWith(prefix));
42
+ }
43
+
44
+ // ── project-memory-path-policy.ts ────────────────────────────────────────────
45
+
46
+ const WRITABLE_MEMORY_FILES = ['CLAUDE.md', 'CLAUDE.local.md', '.ai/memory.md'];
47
+ const COMMANDS_PREFIX = '.claude/commands/';
48
+
49
+ // ── customer-visible-path.ts ─────────────────────────────────────────────────
50
+
51
+ const NOTES_FILE_NAMES = ['CLAUDE.md', 'CLAUDE.local.md'];
52
+ const NOTES_DIR_NAMES = ['.ai', '.claude', '.hiveku'];
53
+
54
+ /** Strip `./` and leading slashes so `/CLAUDE.md` and `./.ai/x` classify like `CLAUDE.md` and `.ai/x`. */
55
+ export function normalizeVersionPath(path) {
56
+ let p = String(path ?? '').trim();
57
+ for (;;) {
58
+ if (p.startsWith('./')) p = p.slice(2);
59
+ else if (p.startsWith('/')) p = p.slice(1);
60
+ else break;
61
+ }
62
+ return p;
63
+ }
64
+
65
+ /** The assistant's notes (versioned, never shown): .ai/*, CLAUDE.md, .claude/**, .hiveku/*, at any depth. */
66
+ export function isAssistantNotesPath(path) {
67
+ const p = normalizeVersionPath(path);
68
+ if (!p) return false;
69
+ if (WRITABLE_MEMORY_FILES.includes(p)) return true;
70
+ if (p.startsWith(COMMANDS_PREFIX)) return true;
71
+ const segments = p.split('/').filter((segment) => segment.length > 0);
72
+ const base = segments[segments.length - 1] ?? '';
73
+ if (NOTES_FILE_NAMES.includes(base)) return true;
74
+ return segments.slice(0, -1).some((segment) => NOTES_DIR_NAMES.includes(segment));
75
+ }
76
+
77
+ /** Should a customer see this path in a version name or change list? */
78
+ export function isCustomerVisiblePath(path) {
79
+ const p = normalizeVersionPath(path);
80
+ if (!p) return false;
81
+ if (isAssistantNotesPath(p)) return false;
82
+ if (isServerManagedFile(p)) return false;
83
+ return true;
84
+ }
85
+
86
+ // ── file-to-route.ts ─────────────────────────────────────────────────────────
87
+
88
+ const ROUTE_FILE_RE = /(^|\/)(page|route|layout|template|loading|error|not-found|default)\.(tsx?|jsx?|mdx?)$/;
89
+
90
+ /** Map an app/ (or pages/) file to its route + kind, or null for a file that defines no route. */
91
+ export function fileToRoute(filePath) {
92
+ const clean = filePath.replace(/^\//, '');
93
+ if (clean === 'src/middleware.ts' || clean === 'middleware.ts') {
94
+ return { route: '(middleware)', kind: 'middleware' };
95
+ }
96
+
97
+ let m = clean.match(/^(?:src\/)?app\/(.*)$/);
98
+ if (m) {
99
+ const rest = m[1];
100
+ if (!ROUTE_FILE_RE.test('/' + rest)) return null;
101
+ const fileBase = rest.split('/').pop() || '';
102
+ const kind = fileBase.startsWith('route.')
103
+ ? 'api'
104
+ : fileBase.startsWith('layout.') || fileBase.startsWith('template.')
105
+ ? 'layout'
106
+ : 'page';
107
+ const segs = rest
108
+ .split('/')
109
+ .slice(0, -1)
110
+ .filter((s) => !(s.startsWith('(') && s.endsWith(')')))
111
+ .filter((s) => !s.startsWith('@'));
112
+ const route = '/' + segs.join('/');
113
+ return { route: route === '/' ? '/' : route.replace(/\/$/, ''), kind };
114
+ }
115
+
116
+ m = clean.match(/^(?:src\/)?pages\/(.*)\.(tsx?|jsx?|mdx?)$/);
117
+ if (m) {
118
+ let p = m[1];
119
+ if (p === 'index' || p.endsWith('/index')) p = p.replace(/\/?index$/, '');
120
+ if (p.startsWith('api/')) return { route: '/' + p, kind: 'api' };
121
+ return { route: '/' + p || '/', kind: 'page' };
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ // ── version-naming.ts ────────────────────────────────────────────────────────
128
+
129
+ export const MAX_VERSION_NAME_LENGTH = 80;
130
+ /** Page and part names are cut to this many characters (29 + "…"). */
131
+ export const MAX_THING_NAME_LENGTH = 30;
132
+
133
+ const LABEL = {
134
+ styles: 'Site styles',
135
+ layout: 'Site layout',
136
+ images: 'Images',
137
+ logic: 'Forms and site logic',
138
+ settings: 'Site settings',
139
+ content: 'Blog content',
140
+ notes: "Assistant's notes",
141
+ other: 'Other files',
142
+ };
143
+
144
+ const PHRASE = {
145
+ styles: 'site styles',
146
+ layout: 'the site layout',
147
+ images: 'images',
148
+ logic: 'forms and site logic',
149
+ settings: 'site settings',
150
+ content: 'blog content',
151
+ notes: "the assistant's notes",
152
+ other: 'other files',
153
+ };
154
+
155
+ const KIND_ORDER = ['page', 'part', 'layout', 'styles', 'content', 'images', 'logic', 'settings', 'other', 'notes'];
156
+
157
+ const SETTINGS_FILE_RE =
158
+ /^(package\.json|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|pnpm-workspace\.yaml|bun\.lockb?|tsconfig([.-][\w.-]+)?\.json|jsconfig\.json|next\.config\.[cm]?[jt]s|next-env\.d\.ts|vercel\.json|netlify\.toml|components\.json|hiveku\.cms\.json|instrumentation\.[jt]s|\.env(\..*)?|\.gitignore|\.npmrc|\.nvmrc|\.node-version|\.eslintrc(\..*)?|eslint\.config\.[cm]?[jt]s|\.prettierrc(\..*)?|prettier\.config\.[cm]?[jt]s|vite\.config\.[cm]?[jt]s|astro\.config\.[cm]?[jt]s)$/;
159
+ const SITE_FILE_RE =
160
+ /^(robots\.(txt|ts|js)|sitemap([-_.][\w-]+)?\.(xml|ts|js)|manifest\.(json|ts|js|webmanifest)|site\.webmanifest|ads\.txt|llms\.txt)$/;
161
+ const STYLE_CONFIG_RE = /^(tailwind|postcss)\.config\.[cm]?[jt]s$/;
162
+ const APP_IMAGE_RE = /^(opengraph-image|twitter-image|icon|apple-icon)(\.alt)?\.(tsx?|jsx?|txt)$/;
163
+ const SPA_SHELL_RE = /^(App|main)\.(tsx|jsx|vue|svelte)$/;
164
+ const TEST_FILE_RE = /\.(test|spec|stories)\.[cm]?[jt]sx?$/;
165
+
166
+ const STYLE_EXTS = ['css', 'scss', 'sass', 'less', 'styl'];
167
+ const FONT_EXTS = ['woff', 'woff2', 'ttf', 'otf', 'eot'];
168
+ const MEDIA_EXTS = [
169
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico', 'bmp', 'tif', 'tiff', 'heic', 'heif',
170
+ 'mp4', 'webm', 'mov', 'm4v', 'mp3', 'wav', 'ogg', 'm4a',
171
+ ];
172
+ const HTML_EXTS = ['html', 'htm'];
173
+ const MARKDOWN_EXTS = ['md', 'mdx', 'markdown'];
174
+ const APP_CODE_EXTS = ['ts', 'tsx', 'js', 'jsx', 'mdx', 'md'];
175
+ const COMPONENT_EXTS = ['tsx', 'jsx', 'vue', 'svelte', 'astro'];
176
+ const SCRIPT_EXTS = ['ts', 'js', 'mjs', 'cjs'];
177
+ const LOGIC_EXTS = ['sql', 'prisma'];
178
+
179
+ const STYLE_DIRS = ['styles', 'theme'];
180
+ const CONTENT_DIRS = ['content', 'posts', '_posts'];
181
+ const PART_DIRS = ['components', '_components', 'sections', '_sections'];
182
+ const LOGIC_DIRS = [
183
+ 'lib', 'utils', 'hooks', 'services', 'server', 'actions', 'api', 'store', 'stores', 'context',
184
+ 'contexts', 'providers', 'helpers', 'types', 'db', 'prisma', 'supabase', 'functions', 'netlify',
185
+ 'middleware',
186
+ ];
187
+ const APP_CUT_DIRS = [
188
+ 'components', 'lib', 'utils', 'hooks', 'sections', 'ui', 'styles', 'types', 'data', 'helpers',
189
+ 'actions', 'constants', 'content', 'assets', 'icons', 'images',
190
+ ];
191
+ const ROUTE_STATE_STEMS = ['error', 'loading', 'default', 'template'];
192
+
193
+ const DYNAMIC_SEGMENT_RE = /^\[.*\]$/;
194
+ const LOCALE_SEGMENT_RE = /^\[(locale|lang|lng|language)\]$/;
195
+ const ACRONYMS = { faq: 'FAQ', faqs: 'FAQs', seo: 'SEO', cta: 'CTA', ai: 'AI' };
196
+
197
+ function fixed(kind) {
198
+ return { kind, name: LABEL[kind], phrase: PHRASE[kind] };
199
+ }
200
+
201
+ function clampName(name) {
202
+ if (name.length <= MAX_THING_NAME_LENGTH) return name;
203
+ return name.slice(0, MAX_THING_NAME_LENGTH - 1).trimEnd() + '…';
204
+ }
205
+
206
+ function isAllCapsWord(word) {
207
+ return word.length > 1 && word === word.toUpperCase() && /[A-Z]/.test(word);
208
+ }
209
+
210
+ /** "old-offer" → "Old offer", "PricingTable" → "Pricing table", "FAQSection" → "FAQ section". */
211
+ export function humanizeSegment(segment) {
212
+ const spaced = segment
213
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
214
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
215
+ .replace(/[-_.\s]+/g, ' ')
216
+ .trim();
217
+ if (!spaced) return '';
218
+ return spaced
219
+ .split(' ')
220
+ .filter((word) => word.length > 0)
221
+ .map((word, index) => {
222
+ const lower = word.toLowerCase();
223
+ const acronym = Object.prototype.hasOwnProperty.call(ACRONYMS, lower) ? ACRONYMS[lower] : undefined;
224
+ if (acronym) return acronym;
225
+ if (isAllCapsWord(word)) return word;
226
+ return index === 0 ? lower.charAt(0).toUpperCase() + lower.slice(1) : lower;
227
+ })
228
+ .join(' ');
229
+ }
230
+
231
+ function lowerFirst(name) {
232
+ const firstWord = name.split(' ')[0] ?? '';
233
+ if (isAllCapsWord(firstWord)) return name;
234
+ return name.charAt(0).toLowerCase() + name.slice(1);
235
+ }
236
+
237
+ function pageFromRoute(rawRoute, pageFile) {
238
+ let segments = rawRoute.split('/').filter((segment) => segment.length > 0);
239
+ if (segments.length > 0 && LOCALE_SEGMENT_RE.test(segments[0])) segments = segments.slice(1);
240
+ const route = '/' + segments.join('/');
241
+ if (segments.length === 0) {
242
+ return { kind: 'page', name: 'Home', phrase: 'the Home page', route: '/', dynamic: false, pageFile };
243
+ }
244
+ const last = segments[segments.length - 1];
245
+ if (DYNAMIC_SEGMENT_RE.test(last)) {
246
+ let parent = null;
247
+ for (let i = segments.length - 2; i >= 0; i--) {
248
+ if (!DYNAMIC_SEGMENT_RE.test(segments[i])) {
249
+ parent = segments[i];
250
+ break;
251
+ }
252
+ }
253
+ const parentName = parent === null ? '' : clampName(humanizeSegment(parent));
254
+ if (!parentName) {
255
+ return { kind: 'page', name: 'All pages', phrase: 'every page', route, dynamic: true, pageFile };
256
+ }
257
+ const name = parent !== null && parent.toLowerCase() === 'blog' ? 'Blog posts' : `${parentName} pages`;
258
+ return { kind: 'page', name, phrase: `each page under ${parentName}`, route, dynamic: true, pageFile };
259
+ }
260
+ const name = clampName(humanizeSegment(last)) || 'Untitled';
261
+ return { kind: 'page', name, phrase: `the ${name} page`, route, dynamic: false, pageFile };
262
+ }
263
+
264
+ function partFrom(rel, stem) {
265
+ const source = stem.toLowerCase() === 'index' && rel.length >= 2 ? rel[rel.length - 2] : stem;
266
+ const words = humanizeSegment(source);
267
+ const bare = /^The .+/.test(words) ? words.slice(4) : words;
268
+ const name = clampName(bare.charAt(0).toUpperCase() + bare.slice(1)) || 'Site section';
269
+ return { kind: 'part', name, phrase: `the ${lowerFirst(name)}` };
270
+ }
271
+
272
+ function inPartFolder(rel) {
273
+ return rel.slice(0, -1).some((segment) => PART_DIRS.includes(segment));
274
+ }
275
+
276
+ function classifyApp(path, rel, stem, ext) {
277
+ const codeFile = APP_CODE_EXTS.includes(ext);
278
+ if (codeFile && stem === 'not-found') return pageFromRoute('/404', true);
279
+ if (codeFile && stem === 'global-error') return fixed('layout');
280
+
281
+ const route = fileToRoute(path);
282
+ if (route) {
283
+ if (route.kind === 'api' || route.kind === 'middleware') return fixed('logic');
284
+ const page = pageFromRoute(route.route, false);
285
+ if (route.kind === 'layout' || ROUTE_STATE_STEMS.includes(stem)) {
286
+ return page.route === '/' ? fixed('layout') : page;
287
+ }
288
+ return { ...page, pageFile: stem === 'page' };
289
+ }
290
+
291
+ const kept = [];
292
+ for (const segment of rel.slice(1, -1)) {
293
+ if (segment.startsWith('_') || APP_CUT_DIRS.includes(segment)) break;
294
+ if (segment.startsWith('(') && segment.endsWith(')')) continue;
295
+ if (segment.startsWith('@')) continue;
296
+ kept.push(segment);
297
+ }
298
+ if (kept[0] === 'api') return fixed('logic');
299
+ const routeSegments = kept.length > 0 && LOCALE_SEGMENT_RE.test(kept[0]) ? kept.slice(1) : kept;
300
+ if (routeSegments.length === 0) {
301
+ if (COMPONENT_EXTS.includes(ext)) return partFrom(rel, stem);
302
+ if (!inPartFolder(rel) && SCRIPT_EXTS.includes(ext)) return fixed('logic');
303
+ return fixed('other');
304
+ }
305
+ return pageFromRoute('/' + kept.join('/'), false);
306
+ }
307
+
308
+ function classifyPagesRouter(path) {
309
+ const route = fileToRoute(path);
310
+ if (!route) return fixed('other');
311
+ if (route.kind === 'api' || route.kind === 'middleware') return fixed('logic');
312
+ const last = route.route.split('/').filter((segment) => segment.length > 0).pop() ?? '';
313
+ if (last === '_app' || last === '_document' || last === '_error') return fixed('layout');
314
+ return pageFromRoute(route.route, true);
315
+ }
316
+
317
+ function classify(input) {
318
+ const path = normalizeVersionPath(input);
319
+ if (!path || !isCustomerVisiblePath(path)) return fixed('notes');
320
+
321
+ const segments = path.split('/').filter((segment) => segment.length > 0);
322
+ const rel = segments.length > 1 && segments[0] === 'src' ? segments.slice(1) : segments;
323
+ const base = rel[rel.length - 1] ?? '';
324
+ const dot = base.lastIndexOf('.');
325
+ const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : '';
326
+ const stem = dot > 0 ? base.slice(0, dot) : base;
327
+ const first = rel[0] ?? '';
328
+ const atRoot = rel.length === 1;
329
+
330
+ if (rel.includes('__tests__') || TEST_FILE_RE.test(base)) return fixed('other');
331
+ if (atRoot && SETTINGS_FILE_RE.test(base)) return fixed('settings');
332
+ if ((atRoot || first === 'app' || first === 'public') && SITE_FILE_RE.test(base)) return fixed('settings');
333
+ if (atRoot && /^middleware\.[cm]?[jt]s$/.test(base)) return fixed('logic');
334
+ if (
335
+ STYLE_EXTS.includes(ext) ||
336
+ FONT_EXTS.includes(ext) ||
337
+ (atRoot && STYLE_CONFIG_RE.test(base)) ||
338
+ (!atRoot && STYLE_DIRS.includes(first))
339
+ ) {
340
+ return fixed('styles');
341
+ }
342
+ if (MEDIA_EXTS.includes(ext)) return fixed('images');
343
+ if (first === 'app' && APP_IMAGE_RE.test(base)) return fixed('images');
344
+ if (HTML_EXTS.includes(ext)) {
345
+ const routeSegments = first === 'public' ? rel.slice(1) : rel.slice();
346
+ routeSegments[routeSegments.length - 1] = stem;
347
+ if (stem.toLowerCase() === 'index') routeSegments.pop();
348
+ return pageFromRoute('/' + routeSegments.join('/'), true);
349
+ }
350
+ if (first === 'public') return fixed('other');
351
+ if (CONTENT_DIRS.includes(first) && !atRoot && MARKDOWN_EXTS.includes(ext)) return fixed('content');
352
+ if (first === 'app' && !atRoot) return classifyApp(path, rel, stem, ext);
353
+ if (first === 'pages' && !atRoot) return classifyPagesRouter(path);
354
+ if (atRoot && SPA_SHELL_RE.test(base)) return fixed('layout');
355
+ if (inPartFolder(rel)) return COMPONENT_EXTS.includes(ext) ? partFrom(rel, stem) : fixed('other');
356
+ if ((!atRoot && LOGIC_DIRS.includes(first)) || LOGIC_EXTS.includes(ext)) return fixed('logic');
357
+ return fixed('other');
358
+ }
359
+
360
+ /** What a file is to the customer: { kind, name, route? }. Never a path or extension in `name`. */
361
+ export function describePath(path) {
362
+ const classified = classify(path);
363
+ return classified.route !== undefined
364
+ ? { kind: classified.kind, name: classified.name, route: classified.route }
365
+ : { kind: classified.kind, name: classified.name };
366
+ }
367
+
368
+ function joinList(items) {
369
+ if (items.length <= 1) return items[0] ?? '';
370
+ return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
371
+ }
372
+
373
+ function plural(count, one, many) {
374
+ return `${count} ${count === 1 ? one : many}`;
375
+ }
376
+
377
+ /** Plain character-code order (UTF-16 units), never locale collation. */
378
+ function compareText(a, b) {
379
+ return a < b ? -1 : a > b ? 1 : 0;
380
+ }
381
+
382
+ const PAGE_VERB = { added: 'Added', modified: 'Edited', removed: 'Removed' };
383
+ const THING_VERBS = ['Added', 'Changed', 'Removed'];
384
+ const STATUS_ORDER = ['added', 'modified', 'removed'];
385
+
386
+ function pageStatus(page) {
387
+ return page.pageFileStatuses.size === 1 ? [...page.pageFileStatuses][0] : 'modified';
388
+ }
389
+
390
+ function thingVerb(thing) {
391
+ if (thing.kind === 'part' || thing.kind === 'images' || thing.kind === 'content') {
392
+ if (thing.statuses.size === 1 && thing.statuses.has('added')) return 'Added';
393
+ if (thing.statuses.size === 1 && thing.statuses.has('removed')) return 'Removed';
394
+ }
395
+ return 'Changed';
396
+ }
397
+
398
+ function pageList(pages, first, countForm) {
399
+ const count = pages.length;
400
+ if (!first) return count === 1 && !countForm ? pages[0].phrase : plural(count, 'other', 'others');
401
+ if (countForm || count >= 4) return plural(count, 'page', 'pages');
402
+ if (pages.every((page) => !page.dynamic)) {
403
+ return `the ${joinList(pages.map((page) => page.name))} ${count === 1 ? 'page' : 'pages'}`;
404
+ }
405
+ return joinList(pages.map((page) => page.phrase));
406
+ }
407
+
408
+ function thingList(things) {
409
+ if (things.length >= 4) return plural(things.length, 'part of the site', 'parts of the site');
410
+ const parts = things.filter((thing) => thing.kind === 'part');
411
+ const items = [];
412
+ let partsWritten = false;
413
+ for (const thing of things) {
414
+ if (thing.kind === 'part' && parts.length >= 3) {
415
+ if (!partsWritten) items.push(plural(parts.length, 'site section', 'site sections'));
416
+ partsWritten = true;
417
+ continue;
418
+ }
419
+ items.push(thing.phrase);
420
+ }
421
+ return joinList(items);
422
+ }
423
+
424
+ function sentence(clauses) {
425
+ const lowered = clauses.map((clause, index) => (index === 0 ? clause : clause.charAt(0).toLowerCase() + clause.slice(1)));
426
+ return joinList(lowered);
427
+ }
428
+
429
+ function filesSentence(counts) {
430
+ const total = counts.added + counts.modified + counts.removed;
431
+ const verb = counts.added === total ? 'Added' : counts.removed === total ? 'Removed' : 'Edited';
432
+ return `${verb} ${plural(total, 'file', 'files')}`;
433
+ }
434
+
435
+ /**
436
+ * A plain version name (≤ 80 characters, never a path or file extension) for
437
+ * the files a change added, modified and removed.
438
+ */
439
+ export function describeChanges(changes = {}) {
440
+ const seen = new Set();
441
+ const pages = new Map();
442
+ const things = new Map();
443
+ const otherCounts = { added: 0, modified: 0, removed: 0 };
444
+ let total = 0;
445
+ let visible = 0;
446
+
447
+ for (const status of STATUS_ORDER) {
448
+ for (const raw of changes[status] ?? []) {
449
+ const path = normalizeVersionPath(raw);
450
+ if (!path || seen.has(path)) continue;
451
+ seen.add(path);
452
+ total++;
453
+ const classified = classify(path);
454
+ if (classified.kind === 'notes') continue;
455
+ visible++;
456
+ if (classified.kind === 'other') {
457
+ otherCounts[status]++;
458
+ continue;
459
+ }
460
+ if (classified.kind === 'page') {
461
+ const key = `${classified.dynamic ? 'dynamic' : 'static'}:${classified.name}`;
462
+ const route = classified.route ?? '/';
463
+ let page = pages.get(key);
464
+ if (!page) {
465
+ page = { name: classified.name, phrase: classified.phrase, route, dynamic: classified.dynamic === true, pageFileStatuses: new Set() };
466
+ pages.set(key, page);
467
+ } else if (compareText(route, page.route) < 0) {
468
+ page.route = route;
469
+ }
470
+ if (classified.pageFile) page.pageFileStatuses.add(status);
471
+ continue;
472
+ }
473
+ const key = `${classified.kind}:${classified.name}`;
474
+ let thing = things.get(key);
475
+ if (!thing) {
476
+ thing = { kind: classified.kind, name: classified.name, phrase: classified.phrase, statuses: new Set() };
477
+ things.set(key, thing);
478
+ }
479
+ thing.statuses.add(status);
480
+ }
481
+ }
482
+
483
+ if (total === 0) return 'No changes';
484
+ if (visible === 0) return "Updated the assistant's notes";
485
+ if (pages.size === 0 && things.size === 0) return filesSentence(otherCounts);
486
+
487
+ const pageGroups = [];
488
+ for (const status of STATUS_ORDER) {
489
+ const group = [...pages.values()]
490
+ .filter((page) => pageStatus(page) === status)
491
+ .sort((a, b) => compareText(a.route, b.route) || compareText(a.name, b.name));
492
+ if (group.length > 0) pageGroups.push({ status, pages: group });
493
+ }
494
+
495
+ const sortedThings = [...things.values()].sort(
496
+ (a, b) => KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) || compareText(a.name, b.name),
497
+ );
498
+ const thingGroups = [];
499
+ for (const verb of THING_VERBS) {
500
+ const group = sortedThings.filter((thing) => thingVerb(thing) === verb);
501
+ if (group.length > 0) thingGroups.push({ verb, things: group });
502
+ }
503
+
504
+ const pageClauses = (countForm) =>
505
+ pageGroups.map((group, index) => `${PAGE_VERB[group.status]} ${pageList(group.pages, index === 0, countForm)}`);
506
+ const thingClauses = thingGroups.map((group) => `${group.verb} ${thingList(group.things)}`);
507
+
508
+ const oneThingClause = things.size > 0 ? [`Changed ${plural(things.size, 'part of the site', 'parts of the site')}`] : [];
509
+ const candidates = [
510
+ sentence([...pageClauses(false), ...thingClauses]),
511
+ sentence([...pageClauses(false), ...oneThingClause]),
512
+ sentence([...pageClauses(true), ...thingClauses]),
513
+ sentence([...pageClauses(true), ...oneThingClause]),
514
+ ];
515
+ for (const candidate of candidates) {
516
+ if (candidate.length <= MAX_VERSION_NAME_LENGTH) return candidate;
517
+ }
518
+ return `Edited ${plural(visible, 'file', 'files')}`;
519
+ }
520
+
521
+ // ── Checking a name someone typed ────────────────────────────────────────────
522
+
523
+ const COMMIT_PREFIX_RE = /^(fix|feat|chore|refactor|docs|style|test|perf|build|ci|revert)(\([^)]*\))?!?:/i;
524
+ /** The exact legacy agent byline ("Hiveku AI:" or "AI:", this case, at the start). */
525
+ const LEGACY_AGENT_BYLINE_RE = /^(?:Hiveku AI|AI):/;
526
+ /** A file extension a site's files actually use (closed list: "e.g." and "St.Patrick's" are fine). */
527
+ const FILE_EXTENSION_RE =
528
+ /\.(tsx?|jsx?|[cm]js|s?css|sass|less|html?|json|mdx?|markdown|ya?ml|toml|xml|txt|svg|png|jpe?g|gif|webp|avif|ico|pdf|mp4|webm|woff2?|vue|svelte|astro|py|sql|prisma|sh|env|lock)\b/i;
529
+
530
+ /** Does `name` start with the exact legacy agent byline ("Hiveku AI:", "AI:")? */
531
+ export function hasLegacyVersionPrefix(name) {
532
+ return LEGACY_AGENT_BYLINE_RE.test(String(name ?? '').trim());
533
+ }
534
+
535
+ /** `name` trimmed, with the exact legacy agent byline removed once from the front. */
536
+ export function stripLegacyVersionPrefix(name) {
537
+ const trimmed = String(name ?? '').trim();
538
+ return LEGACY_AGENT_BYLINE_RE.test(trimmed) ? trimmed.replace(LEGACY_AGENT_BYLINE_RE, '').trim() : trimmed;
539
+ }
540
+
541
+ /**
542
+ * True when `name` reads as a plain version name: non-empty, at most 80
543
+ * characters, no path separator, no file extension, no "fix:"-style prefix and
544
+ * no legacy agent byline.
545
+ */
546
+ export function isPlainVersionName(name) {
547
+ const trimmed = String(name ?? '').trim();
548
+ if (!trimmed || trimmed.length > MAX_VERSION_NAME_LENGTH) return false;
549
+ if (/[\\/]/.test(trimmed)) return false;
550
+ if (FILE_EXTENSION_RE.test(trimmed)) return false;
551
+ if (COMMIT_PREFIX_RE.test(trimmed)) return false;
552
+ if (LEGACY_AGENT_BYLINE_RE.test(trimmed)) return false;
553
+ return true;
554
+ }
555
+
556
+ /**
557
+ * Why a typed name is not a plain version name, in words for the person who
558
+ * typed it, or null when it is plain.
559
+ */
560
+ export function versionNameProblem(name) {
561
+ const trimmed = String(name ?? '').trim();
562
+ if (!trimmed) return 'it is empty';
563
+ if (trimmed.length > MAX_VERSION_NAME_LENGTH) return `it is longer than ${MAX_VERSION_NAME_LENGTH} characters`;
564
+ if (/[\\/]/.test(trimmed)) return 'it contains a file path';
565
+ if (FILE_EXTENSION_RE.test(trimmed)) return 'it contains a file name';
566
+ if (COMMIT_PREFIX_RE.test(trimmed)) return 'it starts with a developer prefix like "fix:"';
567
+ if (LEGACY_AGENT_BYLINE_RE.test(trimmed)) return 'it starts with "AI:" (the history already shows who made each version)';
568
+ return null;
569
+ }