@glossic/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1136 @@
1
+ import fs2 from 'fs/promises';
2
+ import path3 from 'path';
3
+ import { z } from 'zod';
4
+ import { sortBy, ManifestSchema, MANIFEST_VERSION, toPosix, GlossicConfigSchema, compareStrings, isRetryableProviderError, ProviderError } from '@glossic/schema';
5
+ export { compareStrings, joinPosix, relativePosix, sortBy, toPosix } from '@glossic/schema';
6
+ import { glob } from 'tinyglobby';
7
+ import { parse } from 'yaml';
8
+ import { createJiti } from 'jiti';
9
+ import picomatch from 'picomatch';
10
+
11
+ // package.json
12
+ var package_default = {
13
+ version: "0.1.0"};
14
+ var pathExists = async (target) => {
15
+ try {
16
+ await fs2.access(target);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ };
22
+ var readText = async (target) => {
23
+ try {
24
+ return await fs2.readFile(target, "utf8");
25
+ } catch {
26
+ return void 0;
27
+ }
28
+ };
29
+ var readJson = async (target) => {
30
+ const raw = await readText(target);
31
+ if (raw === void 0) {
32
+ return void 0;
33
+ }
34
+ try {
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return void 0;
38
+ }
39
+ };
40
+
41
+ // src/cache.ts
42
+ var DEFAULT_CACHE_PATH = ".glossic/cache.json";
43
+ var CACHE_VERSION = "1";
44
+ var CacheEntrySchema = z.object({
45
+ unitId: z.string().min(1),
46
+ unitHash: z.string().min(1),
47
+ promptVersion: z.string().min(1),
48
+ model: z.string().min(1),
49
+ lang: z.string().min(1),
50
+ outputPath: z.string().min(1),
51
+ generatedAt: z.string().min(1)
52
+ });
53
+ var CacheFileSchema = z.object({
54
+ version: z.string().min(1),
55
+ entries: z.array(CacheEntrySchema)
56
+ });
57
+ var emptyCache = () => ({ version: CACHE_VERSION, entries: [] });
58
+ var readCache = async (target) => {
59
+ try {
60
+ const raw = await fs2.readFile(path3.resolve(target), "utf8");
61
+ const parsed = CacheFileSchema.parse(JSON.parse(raw));
62
+ return parsed.version === CACHE_VERSION ? parsed : emptyCache();
63
+ } catch {
64
+ return emptyCache();
65
+ }
66
+ };
67
+ var serializeCache = (cache) => {
68
+ return `${JSON.stringify({ ...cache, entries: sortBy(cache.entries, (entry) => entry.unitId) }, null, 2)}
69
+ `;
70
+ };
71
+ var writeCache = async (cache, target) => {
72
+ const absolute = path3.resolve(target);
73
+ await fs2.mkdir(path3.dirname(absolute), { recursive: true });
74
+ await fs2.writeFile(absolute, serializeCache(cache), "utf8");
75
+ return absolute;
76
+ };
77
+ var indexCache = (cache) => {
78
+ return new Map(cache.entries.map((entry) => [entry.unitId, entry]));
79
+ };
80
+ var DEFAULT_MANIFEST_PATH = ".glossic/manifest.json";
81
+ var sortUnit = (unit) => ({
82
+ ...unit,
83
+ facts: {
84
+ ...unit.facts,
85
+ base: {
86
+ ...unit.facts.base,
87
+ files: sortBy(unit.facts.base.files, (file) => file.path),
88
+ languages: [...unit.facts.base.languages].sort(
89
+ (a, b) => b.count - a.count || compareStrings(a.language, b.language)
90
+ )
91
+ },
92
+ producedBy: [...unit.facts.producedBy].sort(compareStrings)
93
+ }
94
+ });
95
+ var compareRelations = (a, b) => {
96
+ return compareStrings(a.from, b.from) || compareStrings(a.to, b.to) || compareStrings(a.kind, b.kind);
97
+ };
98
+ var buildManifest = (workspace, results, options = {}) => {
99
+ const units = results.flatMap((result) => result.units).map(sortUnit);
100
+ const relations = results.flatMap((result) => result.relations);
101
+ return ManifestSchema.parse({
102
+ version: MANIFEST_VERSION,
103
+ generatedAt: options.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
104
+ workspace: {
105
+ ...workspace,
106
+ projects: sortBy(workspace.projects, (project) => project.id)
107
+ },
108
+ units: sortBy(units, (unit) => unit.id),
109
+ relations: [...relations].sort(compareRelations)
110
+ });
111
+ };
112
+ var serializeManifest = (manifest) => {
113
+ return `${JSON.stringify(manifest, null, 2)}
114
+ `;
115
+ };
116
+ var writeManifest = async (manifest, target) => {
117
+ const absolute = path3.resolve(target);
118
+ await fs2.mkdir(path3.dirname(absolute), { recursive: true });
119
+ await fs2.writeFile(absolute, serializeManifest(manifest), "utf8");
120
+ return absolute;
121
+ };
122
+ var readManifest = async (target) => {
123
+ try {
124
+ const raw = await fs2.readFile(path3.resolve(target), "utf8");
125
+ return ManifestSchema.parse(JSON.parse(raw));
126
+ } catch {
127
+ return void 0;
128
+ }
129
+ };
130
+ var GLOB_IGNORES = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.git/**"];
131
+ var LOCKFILE_PACKAGE_MANAGERS = [
132
+ ["pnpm-lock.yaml", "pnpm"],
133
+ ["yarn.lock", "yarn"],
134
+ ["package-lock.json", "npm"],
135
+ ["bun.lock", "bun"],
136
+ ["bun.lockb", "bun"],
137
+ ["composer.lock", "composer"],
138
+ ["composer.json", "composer"]
139
+ ];
140
+ var asStringArray = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
141
+ var detectPackageManager = async (dir, pkg) => {
142
+ const declared = pkg?.packageManager;
143
+ if (typeof declared === "string" && declared.length > 0) {
144
+ const [name] = declared.split("@");
145
+ if (name !== void 0 && name.length > 0) {
146
+ return name;
147
+ }
148
+ }
149
+ for (const [lockfile, manager] of LOCKFILE_PACKAGE_MANAGERS) {
150
+ if (await pathExists(path3.join(dir, lockfile))) {
151
+ return manager;
152
+ }
153
+ }
154
+ return void 0;
155
+ };
156
+ var detectMonorepo = async (root, pkg) => {
157
+ const pnpmWorkspace = await readText(path3.join(root, "pnpm-workspace.yaml"));
158
+ if (pnpmWorkspace !== void 0) {
159
+ const parsed = parse(pnpmWorkspace);
160
+ const globs = asStringArray(parsed?.packages);
161
+ if (globs.length > 0) {
162
+ return { tool: "pnpm", globs };
163
+ }
164
+ }
165
+ const workspaces = Array.isArray(pkg?.workspaces) ? pkg.workspaces : asStringArray(pkg?.workspaces?.packages);
166
+ if (workspaces.length > 0) {
167
+ return { tool: "npm-workspaces", globs: workspaces };
168
+ }
169
+ if (await pathExists(path3.join(root, "turbo.json"))) {
170
+ return { tool: "turbo", globs: ["apps/*", "packages/*"] };
171
+ }
172
+ if (await pathExists(path3.join(root, "nx.json"))) {
173
+ return { tool: "nx", globs: ["apps/*", "libs/*", "packages/*"] };
174
+ }
175
+ const lerna = await readJson(path3.join(root, "lerna.json"));
176
+ if (lerna !== void 0) {
177
+ const globs = asStringArray(lerna.packages);
178
+ return { tool: "lerna", globs: globs.length > 0 ? globs : ["packages/*"] };
179
+ }
180
+ return void 0;
181
+ };
182
+ var expandProjectDirs = async (root, globs) => {
183
+ const patterns = [];
184
+ const ignore = [...GLOB_IGNORES];
185
+ for (const entry of globs) {
186
+ if (entry.startsWith("!")) {
187
+ ignore.push(`${entry.slice(1)}/**`);
188
+ continue;
189
+ }
190
+ patterns.push(`${entry}/package.json`);
191
+ }
192
+ if (patterns.length === 0) return [];
193
+ const manifests = await glob({
194
+ patterns,
195
+ cwd: root,
196
+ ignore,
197
+ onlyFiles: true,
198
+ followSymbolicLinks: false,
199
+ dot: false
200
+ });
201
+ const dirs = manifests.map((manifest) => toPosix(path3.posix.dirname(toPosix(manifest))));
202
+ return [...new Set(dirs)].sort();
203
+ };
204
+ var buildProject = async (root, rootDir, fallbackManager) => {
205
+ const dir = path3.resolve(root, rootDir);
206
+ const pkg = await readJson(path3.join(dir, "package.json"));
207
+ const packageManager = await detectPackageManager(dir, pkg) ?? fallbackManager;
208
+ const name = pkg?.name ?? path3.basename(dir);
209
+ const project = {
210
+ id: rootDir === "." ? "root" : rootDir,
211
+ name,
212
+ rootDir
213
+ };
214
+ return packageManager === void 0 ? project : { ...project, packageManager };
215
+ };
216
+ var resolveWorkspace = async (root) => {
217
+ const absoluteRoot = path3.resolve(root);
218
+ const pkg = await readJson(path3.join(absoluteRoot, "package.json"));
219
+ const packageManager = await detectPackageManager(absoluteRoot, pkg);
220
+ const name = pkg?.name ?? path3.basename(absoluteRoot);
221
+ const marker = await detectMonorepo(absoluteRoot, pkg);
222
+ const projectDirs = marker === void 0 ? [] : await expandProjectDirs(absoluteRoot, marker.globs);
223
+ const isMonorepo = projectDirs.length > 0;
224
+ const rootDirs = isMonorepo ? projectDirs : ["."];
225
+ const projects = await Promise.all(
226
+ rootDirs.map((rootDir) => buildProject(absoluteRoot, rootDir, packageManager))
227
+ );
228
+ const workspace = {
229
+ name,
230
+ root: toPosix(absoluteRoot),
231
+ isMonorepo,
232
+ tool: isMonorepo && marker !== void 0 ? marker.tool : "none",
233
+ projects: sortBy(projects, (project) => project.id)
234
+ };
235
+ return packageManager === void 0 ? workspace : { ...workspace, packageManager };
236
+ };
237
+
238
+ // src/scan.ts
239
+ var orderAdapters = (adapters, wanted) => {
240
+ const byName = new Map(adapters.map((adapter) => [adapter.name, adapter]));
241
+ return wanted.map((name) => byName.get(name)).filter((adapter) => adapter !== void 0);
242
+ };
243
+ var selectAdapter = async (adapters, ctx) => {
244
+ for (const adapter of adapters) {
245
+ if (await adapter.detect(ctx)) {
246
+ return adapter;
247
+ }
248
+ }
249
+ return void 0;
250
+ };
251
+ var scan = async (ctx) => {
252
+ const config = ctx.config ?? GlossicConfigSchema.parse({});
253
+ const workspace = await resolveWorkspace(path3.resolve(ctx.root));
254
+ const adapters = orderAdapters(ctx.adapters, config.adapters);
255
+ const adapterContext = { root: workspace.root, workspace, config };
256
+ const results = [];
257
+ const adapterByProject = {};
258
+ for (const project of workspace.projects) {
259
+ const discoverContext = { ...adapterContext, project };
260
+ const adapter = await selectAdapter(adapters, discoverContext);
261
+ if (adapter === void 0) continue;
262
+ adapterByProject[project.id] = adapter.name;
263
+ const units = await adapter.discover(discoverContext);
264
+ results.push(await adapter.extract({ ...discoverContext, units }));
265
+ }
266
+ const manifest = buildManifest(
267
+ workspace,
268
+ results,
269
+ ctx.generatedAt === void 0 ? {} : { generatedAt: ctx.generatedAt }
270
+ );
271
+ return { manifest, workspace, adapterByProject };
272
+ };
273
+
274
+ // src/markdown.ts
275
+ var unitDocPath = (unit) => {
276
+ return unit.path === "." ? "root.md" : `${unit.path}.md`;
277
+ };
278
+ var INDEX_DOC_PATH = "index.md";
279
+ var frontmatter = (entries) => [
280
+ "---",
281
+ ...entries.map(
282
+ ([key, value]) => typeof value === "number" ? `${key}: ${value}` : `${key}: ${JSON.stringify(value)}`
283
+ ),
284
+ "---"
285
+ ].join("\n");
286
+ var renderUnitDoc = (input) => {
287
+ const { unit } = input;
288
+ const title = unit.name === "root" ? input.project.name : unit.name;
289
+ const entries = [
290
+ ["title", title],
291
+ ["unit", unit.id],
292
+ ["project", unit.projectId],
293
+ ["path", unit.path],
294
+ ["hash", unit.hash],
295
+ ["files", unit.facts.base.files.length],
296
+ ["generatedAt", input.generatedAt]
297
+ ];
298
+ if (unit.facts.base.roleHint !== null) {
299
+ entries.splice(4, 0, ["role", unit.facts.base.roleHint]);
300
+ }
301
+ return [frontmatter(entries), "", input.body.trim(), ""].join("\n");
302
+ };
303
+ var languageSummary = (units) => {
304
+ const totals = /* @__PURE__ */ new Map();
305
+ for (const unit of units) {
306
+ for (const entry of unit.facts.base.languages) {
307
+ totals.set(entry.language, (totals.get(entry.language) ?? 0) + entry.count);
308
+ }
309
+ }
310
+ return [...totals.entries()].sort(([aLang, aCount], [bLang, bCount]) => bCount - aCount || compareStrings(aLang, bLang)).map(([language, count]) => `${language} (${count})`).join(", ");
311
+ };
312
+ var renderIndexDoc = (input) => {
313
+ const { manifest } = input;
314
+ const { workspace, units } = manifest;
315
+ const lines = [
316
+ frontmatter([
317
+ ["title", workspace.name],
318
+ ["generatedAt", input.generatedAt],
319
+ ["units", units.length]
320
+ ]),
321
+ "",
322
+ `# ${workspace.name}`,
323
+ "",
324
+ workspace.isMonorepo ? `${workspace.tool} monorepo with ${workspace.projects.length} projects.` : "Single-project workspace.",
325
+ ""
326
+ ];
327
+ for (const project of workspace.projects) {
328
+ const projectUnits = units.filter((unit) => unit.projectId === project.id);
329
+ lines.push(`## ${project.name}`, "");
330
+ if (projectUnits.length === 0) {
331
+ lines.push("No documented units.", "");
332
+ continue;
333
+ }
334
+ for (const unit of projectUnits) {
335
+ const role = unit.facts.base.roleHint;
336
+ const suffix = role === null ? "" : ` \u2014 ${role}`;
337
+ lines.push(
338
+ `- [${unit.name}](./${unitDocPath(unit)}) \u2014 ${unit.facts.base.files.length} files${suffix}`
339
+ );
340
+ }
341
+ lines.push("");
342
+ }
343
+ const languages = languageSummary(units);
344
+ if (languages !== "") {
345
+ lines.push(`Languages: ${languages}`, "");
346
+ }
347
+ return lines.join("\n");
348
+ };
349
+
350
+ // src/check.ts
351
+ var FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
352
+ var readDocFrontmatter = async (file) => {
353
+ try {
354
+ const raw = await fs2.readFile(file, "utf8");
355
+ const match = FRONTMATTER.exec(raw);
356
+ if (match === null) {
357
+ return { unit: void 0, hash: void 0 };
358
+ }
359
+ const parsed = parse(match[1] ?? "");
360
+ if (typeof parsed !== "object" || parsed === null) {
361
+ return { unit: void 0, hash: void 0 };
362
+ }
363
+ const record = parsed;
364
+ return {
365
+ unit: typeof record.unit === "string" ? record.unit : void 0,
366
+ hash: typeof record.hash === "string" ? record.hash : void 0
367
+ };
368
+ } catch {
369
+ return { unit: void 0, hash: void 0 };
370
+ }
371
+ };
372
+ var listDocs = async (outDir) => {
373
+ try {
374
+ const entries = await glob({
375
+ patterns: ["**/*.md"],
376
+ cwd: outDir,
377
+ onlyFiles: true,
378
+ followSymbolicLinks: false
379
+ });
380
+ return entries.map(toPosix).sort(compareStrings);
381
+ } catch {
382
+ return [];
383
+ }
384
+ };
385
+ var check = async (ctx) => {
386
+ const { manifest } = await scan(ctx);
387
+ const docs = await listDocs(ctx.outDir);
388
+ const expected = new Map(manifest.units.map((unit) => [unitDocPath(unit), unit]));
389
+ const upToDate = [];
390
+ const missing = [];
391
+ const stale = [];
392
+ for (const unit of manifest.units) {
393
+ const docPath = unitDocPath(unit);
394
+ const entryBase = { unitId: unit.id, docPath, expectedHash: unit.hash };
395
+ if (!docs.includes(docPath)) {
396
+ missing.push({ ...entryBase, documentedHash: void 0 });
397
+ continue;
398
+ }
399
+ const { hash } = await readDocFrontmatter(path3.resolve(ctx.outDir, docPath));
400
+ if (hash === unit.hash) {
401
+ upToDate.push({ ...entryBase, documentedHash: hash });
402
+ } else {
403
+ stale.push({ ...entryBase, documentedHash: hash });
404
+ }
405
+ }
406
+ const orphaned = docs.filter((doc) => doc !== INDEX_DOC_PATH && !expected.has(doc)).sort(compareStrings);
407
+ const byUnitId = (entries) => {
408
+ return sortBy(entries, (entry) => entry.unitId);
409
+ };
410
+ return {
411
+ outDir: toPosix(ctx.outDir),
412
+ upToDate: byUnitId(upToDate),
413
+ missing: byUnitId(missing),
414
+ stale: byUnitId(stale),
415
+ orphaned,
416
+ ok: missing.length === 0 && stale.length === 0 && orphaned.length === 0
417
+ };
418
+ };
419
+ var CONFIG_FILENAMES = [
420
+ "glossic.config.ts",
421
+ "glossic.config.mts",
422
+ "glossic.config.js",
423
+ "glossic.config.mjs"
424
+ ];
425
+ var findConfigFile = async (root) => {
426
+ for (const filename of CONFIG_FILENAMES) {
427
+ const candidate = path3.resolve(root, filename);
428
+ if (await pathExists(candidate)) {
429
+ return toPosix(candidate);
430
+ }
431
+ }
432
+ return void 0;
433
+ };
434
+ var asUserConfig = (value) => {
435
+ if (typeof value !== "object" || value === null) {
436
+ return void 0;
437
+ }
438
+ const parsed = GlossicConfigSchema.partial().safeParse(value);
439
+ if (!parsed.success) {
440
+ return void 0;
441
+ }
442
+ const declared = Object.keys(value);
443
+ return Object.fromEntries(
444
+ Object.entries(parsed.data).filter(([key]) => declared.includes(key))
445
+ );
446
+ };
447
+ var loadProjectConfig = async (root) => {
448
+ const file = await findConfigFile(root);
449
+ if (file === void 0) {
450
+ return void 0;
451
+ }
452
+ try {
453
+ const jiti = createJiti(import.meta.url, { moduleCache: false });
454
+ const loaded = await jiti.import(file, { default: true });
455
+ const values = asUserConfig(loaded);
456
+ return values === void 0 ? void 0 : { file, values };
457
+ } catch {
458
+ return void 0;
459
+ }
460
+ };
461
+ var ORDER = [
462
+ ["flag", "flags"],
463
+ ["project", "project"],
464
+ ["preference", "preference"]
465
+ ];
466
+ var isSet = (value) => {
467
+ return value !== void 0 && !(typeof value === "string" && value.trim() === "");
468
+ };
469
+ var resolveConfig = (sources = {}) => {
470
+ const merged = {};
471
+ const origins = {};
472
+ for (const [origin, key] of [...ORDER].reverse()) {
473
+ const source = sources[key];
474
+ if (source === void 0) continue;
475
+ for (const [name, value] of Object.entries(source)) {
476
+ if (!isSet(value)) continue;
477
+ merged[name] = value;
478
+ origins[name] = origin;
479
+ }
480
+ }
481
+ const config = GlossicConfigSchema.parse(merged);
482
+ for (const name of Object.keys(config)) {
483
+ origins[name] ??= "default";
484
+ }
485
+ return { config, origins };
486
+ };
487
+ var GROUPING_KEYS = [
488
+ "include",
489
+ "exclude",
490
+ "ignoreUnits",
491
+ "excludeFromContent",
492
+ "mergeChildrenInto",
493
+ "minUnitFiles",
494
+ "maxUnitFiles"
495
+ ];
496
+
497
+ // src/errors.ts
498
+ var NotImplementedError = class extends Error {
499
+ constructor(what) {
500
+ super(`${what} is not implemented`);
501
+ this.name = "NotImplementedError";
502
+ }
503
+ };
504
+ var NoProviderAvailableError = class extends Error {
505
+ tried;
506
+ constructor(tried) {
507
+ super(
508
+ [
509
+ "No LLM provider is available.",
510
+ "",
511
+ "glossic needs one of these two:",
512
+ "",
513
+ " 1. Claude Code \u2014 install the CLI and sign in:",
514
+ " https://claude.com/claude-code",
515
+ " glossic picks it up as soon as `claude --version` works.",
516
+ "",
517
+ " 2. Anthropic API \u2014 export an API key:",
518
+ " export ANTHROPIC_API_KEY=sk-ant-...",
519
+ " https://console.anthropic.com/settings/keys",
520
+ "",
521
+ "Run `glossic doctor` to see what glossic can find on this machine."
522
+ ].join("\n")
523
+ );
524
+ this.name = "NoProviderAvailableError";
525
+ this.tried = [...tried];
526
+ }
527
+ };
528
+ var UnknownProviderError = class extends Error {
529
+ constructor(requested, known) {
530
+ super(`unknown provider "${requested}". Available: ${[...known].sort().join(", ")}`);
531
+ this.name = "UnknownProviderError";
532
+ }
533
+ };
534
+ var DEFAULT_ATTEMPTS = 3;
535
+ var DEFAULT_BASE_DELAY_MS = 500;
536
+ var defaultSleep = (ms) => {
537
+ return new Promise((resolve) => {
538
+ setTimeout(resolve, ms).unref?.();
539
+ });
540
+ };
541
+ var backoffDelay = (attempt, baseDelayMs) => {
542
+ return baseDelayMs * 2 ** (attempt - 1);
543
+ };
544
+ var withRetry = async (task, options = {}) => {
545
+ const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
546
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
547
+ const sleep = options.sleep ?? defaultSleep;
548
+ let lastError;
549
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
550
+ try {
551
+ return await task();
552
+ } catch (error) {
553
+ lastError = error;
554
+ if (attempt === attempts || !isRetryableProviderError(error)) {
555
+ throw error;
556
+ }
557
+ options.onRetry?.(attempt, error);
558
+ await sleep(backoffDelay(attempt, baseDelayMs));
559
+ }
560
+ }
561
+ throw lastError;
562
+ };
563
+ var MAX_FILE_BYTES = 24e3;
564
+ var CHARS_PER_TOKEN = 4;
565
+ var PROMPT_VERSION = "3";
566
+ var SYSTEM_PROMPT = [
567
+ "You are a technical writer documenting a codebase for the engineers who work on it.",
568
+ "",
569
+ "You are given one unit of code: a directory, its extracted facts and the full",
570
+ "content of its source files. Write reference documentation for that unit.",
571
+ "",
572
+ "Cover, in this order:",
573
+ " 1. What the unit does, in one or two sentences.",
574
+ " 2. Its responsibilities, and what it deliberately leaves to other units.",
575
+ " 3. The important public elements (exported classes, functions, types,",
576
+ " endpoints, commands) and what each is for.",
577
+ " 4. Architectural decisions that are visible in the code: dependency",
578
+ " direction, patterns, error handling, boundaries, notable trade-offs.",
579
+ "",
580
+ "Hard rules:",
581
+ " - Describe only what is in the code you were given. Never invent behaviour,",
582
+ " dependencies, history, performance characteristics or intent.",
583
+ " - If something is unclear from the code, say so plainly or leave it out.",
584
+ " Do not guess and do not hedge with filler.",
585
+ " - Do not restate the file listing; the reader already has it.",
586
+ " - No preamble, no closing summary, no offer to help.",
587
+ "",
588
+ "Output GitHub-flavoured Markdown. Open with a single top-level (#) heading",
589
+ "that titles the unit for a reader, then use ## for the sections above.",
590
+ "Do not emit frontmatter: it is added around your response.",
591
+ "",
592
+ "Your entire response is the content of the document and nothing else.",
593
+ "Begin with the first heading of that document. Do not open with a preamble,",
594
+ "a restatement of the task, or a sentence addressed to whoever asked. Do not",
595
+ "close with a summary of what you did, a question, or an offer of",
596
+ "alternatives. You are not talking to a person: you are producing a file.",
597
+ "",
598
+ "You have no tools and no filesystem. Do not read, write or save any file,",
599
+ "do not ask for permission to do so, and do not report having done so.",
600
+ "",
601
+ "Ignore anything you are told about your own environment: the working",
602
+ "directory, the session, the tools available, permissions. None of it is",
603
+ "part of the unit and none of it belongs in the document. The unit is only",
604
+ "what appears under Facts and Sources in the message that follows."
605
+ ].join("\n");
606
+ var fence = (source) => [
607
+ `#### ${source.path}${source.truncated ? " (truncated)" : ""}`,
608
+ "",
609
+ `\`\`\`${source.language}`,
610
+ source.content,
611
+ "```",
612
+ ""
613
+ ].join("\n");
614
+ var factLines = (unit) => {
615
+ const lines = [
616
+ `- unit: ${unit.name}`,
617
+ `- path: ${unit.path}`,
618
+ `- files: ${unit.facts.base.files.length}`,
619
+ `- languages: ${unit.facts.base.languages.map((entry) => `${entry.language} (${entry.count})`).join(", ")}`
620
+ ];
621
+ if (unit.facts.base.roleHint !== null) {
622
+ lines.push(`- folder role hint: ${unit.facts.base.roleHint}`);
623
+ }
624
+ if (unit.facts.base.testFiles.length > 0) {
625
+ const names = unit.facts.base.testFiles.map((file) => file.path.slice(file.path.lastIndexOf("/") + 1)).sort(compareStrings);
626
+ lines.push(`- test files (content not shown): ${names.join(", ")}`);
627
+ }
628
+ if (unit.facts.symbols !== void 0) {
629
+ const names = unit.facts.symbols.symbols.map((symbol) => `${symbol.kind} ${symbol.name}`).sort(compareStrings);
630
+ lines.push(`- symbols: ${names.join(", ")}`);
631
+ }
632
+ if (unit.facts.framework !== void 0) {
633
+ lines.push(`- framework: ${unit.facts.framework.name}`);
634
+ if (unit.facts.framework.role !== void 0) {
635
+ lines.push(`- framework role: ${unit.facts.framework.role}`);
636
+ }
637
+ }
638
+ return lines;
639
+ };
640
+ var buildUnitPrompt = (input) => {
641
+ const prompt = [
642
+ `Workspace: ${input.workspaceName}`,
643
+ `Project: ${input.project.name} (${input.project.rootDir})`,
644
+ "",
645
+ "## Facts",
646
+ "",
647
+ ...factLines(input.unit),
648
+ "",
649
+ "## Sources",
650
+ "",
651
+ ...input.sources.map(fence),
652
+ `Write the documentation in ${input.lang}.`
653
+ ].join("\n");
654
+ return {
655
+ system: SYSTEM_PROMPT,
656
+ prompt,
657
+ ...input.model === void 0 ? {} : { model: input.model },
658
+ ...input.temperature === void 0 ? {} : { temperature: input.temperature },
659
+ metadata: { unitId: input.unit.id, projectId: input.unit.projectId }
660
+ };
661
+ };
662
+ var readUnitSources = async (root, unit) => {
663
+ const sources = await Promise.all(
664
+ unit.facts.base.files.map(async (file) => {
665
+ const raw = await fs2.readFile(path3.resolve(root, file.path), "utf8");
666
+ const truncated = raw.length > MAX_FILE_BYTES;
667
+ return {
668
+ path: file.path,
669
+ language: file.language,
670
+ content: truncated ? raw.slice(0, MAX_FILE_BYTES) : raw,
671
+ truncated
672
+ };
673
+ })
674
+ );
675
+ return sources;
676
+ };
677
+ var estimateTokens = (request) => {
678
+ return Math.ceil(((request.system?.length ?? 0) + request.prompt.length) / CHARS_PER_TOKEN);
679
+ };
680
+
681
+ // src/utils/concurrency.ts
682
+ var mapWithConcurrency = async (items, limit, task) => {
683
+ const results = new Array(items.length);
684
+ let cursor = 0;
685
+ const worker = async () => {
686
+ while (cursor < items.length) {
687
+ const index = cursor;
688
+ cursor += 1;
689
+ const item = items[index];
690
+ if (item === void 0) continue;
691
+ results[index] = await task(item);
692
+ }
693
+ };
694
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
695
+ return results;
696
+ };
697
+ var writeDoc = async (outDir, relative, content) => {
698
+ const target = path3.resolve(outDir, relative);
699
+ await fs2.mkdir(path3.dirname(target), { recursive: true });
700
+ await fs2.writeFile(target, content, "utf8");
701
+ };
702
+ var buildJobs = async (manifest, config, root) => {
703
+ const projectById = new Map(manifest.workspace.projects.map((entry) => [entry.id, entry]));
704
+ const jobs = await Promise.all(
705
+ manifest.units.map(async (unit) => {
706
+ const project = projectById.get(unit.projectId);
707
+ if (project === void 0) {
708
+ return void 0;
709
+ }
710
+ const request = buildUnitPrompt({
711
+ unit,
712
+ project,
713
+ workspaceName: manifest.workspace.name,
714
+ sources: await readUnitSources(root, unit),
715
+ lang: config.lang,
716
+ model: config.model,
717
+ temperature: config.temperature
718
+ });
719
+ return {
720
+ unit,
721
+ project,
722
+ request,
723
+ docPath: unitDocPath(unit),
724
+ estimatedTokens: estimateTokens(request)
725
+ };
726
+ })
727
+ );
728
+ return jobs.filter((job) => job !== void 0);
729
+ };
730
+ var modelCacheKey = (config) => config.model ?? "default";
731
+ var decide = async (job, entry, context) => {
732
+ if (context.force) {
733
+ return "forced";
734
+ }
735
+ if (entry === void 0) {
736
+ return "new";
737
+ }
738
+ if (entry.unitHash !== job.unit.hash) {
739
+ return "content-changed";
740
+ }
741
+ if (entry.promptVersion !== PROMPT_VERSION) {
742
+ return "prompt-version-changed";
743
+ }
744
+ if (entry.model !== context.model) {
745
+ return "model-changed";
746
+ }
747
+ if (entry.lang !== context.lang) {
748
+ return "lang-changed";
749
+ }
750
+ if (!await pathExists(path3.resolve(context.outDir, job.docPath))) {
751
+ return "output-missing";
752
+ }
753
+ return "cached";
754
+ };
755
+ var MIN_DOCUMENT_LENGTH = 200;
756
+ var MAX_PREAMBLE_LENGTH = 500;
757
+ var CONVERSATIONAL_RULES = [
758
+ {
759
+ reason: "the model asked for permission instead of writing the document",
760
+ pattern: /\b(permission to (write|save|create)|(write|read) permission|need (write )?access)\b/i
761
+ },
762
+ {
763
+ reason: "the model reported on saving a file instead of writing the document",
764
+ pattern: /\bI( have|['’]ve)? ?(drafted|prepared|written|created|saved|generated) (the|this|a) /i
765
+ },
766
+ {
767
+ reason: "the model asked the reader a question",
768
+ pattern: /\b(let me know|say the word|shall I|would you (like|prefer|want))\b/i
769
+ },
770
+ {
771
+ reason: "the model opened with a preamble instead of the document",
772
+ pattern: /^\s*(here(['’]s| is)|below is|sure[,!]|certainly[,!]|I['’]ll)\b/i
773
+ },
774
+ {
775
+ reason: "the model addressed the reader in the first person",
776
+ pattern: /\bI['’](ve|m|ll|d)\b/
777
+ },
778
+ {
779
+ reason: "the model addressed the reader in the first person",
780
+ pattern: /\bI (need|cannot|can't|could not|couldn't|have|am|was|tried|noticed|assume)\b/
781
+ }
782
+ ];
783
+ var FENCE = /^\s{0,3}(```|~~~)/;
784
+ var TOP_HEADING = /^\s{0,3}#{1,2}\s+\S/;
785
+ var scanLines = (text) => {
786
+ const lines = [];
787
+ let fence2;
788
+ for (const line of text.split(/\r?\n/)) {
789
+ const match = FENCE.exec(line);
790
+ if (fence2 === void 0) {
791
+ if (match !== null) {
792
+ fence2 = match[1];
793
+ }
794
+ lines.push({ text: line, fenced: match !== null });
795
+ continue;
796
+ }
797
+ lines.push({ text: line, fenced: true });
798
+ if (match !== null && match[1] === fence2) {
799
+ fence2 = void 0;
800
+ }
801
+ }
802
+ return lines;
803
+ };
804
+ var invalidContent = (providerName, reason, detail) => new ProviderError({
805
+ provider: providerName,
806
+ code: "invalid-content",
807
+ message: `the response is not a document: ${reason}`,
808
+ detail
809
+ });
810
+ var excerpt = (text, limit) => {
811
+ const flat = text.replace(/\s+/g, " ").trim();
812
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}\u2026`;
813
+ };
814
+ var normalizeDocument = (providerName, text) => {
815
+ const lines = scanLines(text);
816
+ const first = lines.findIndex((line) => !line.fenced && TOP_HEADING.test(line.text));
817
+ if (first === -1) {
818
+ throw invalidContent(
819
+ providerName,
820
+ "it contains no markdown heading",
821
+ excerpt(text, 120) || "(empty)"
822
+ );
823
+ }
824
+ const dropped = lines.slice(0, first).map((line) => line.text).join("\n").trim();
825
+ if (dropped.length > MAX_PREAMBLE_LENGTH) {
826
+ throw invalidContent(
827
+ providerName,
828
+ `${dropped.length} characters of prose precede the first heading, over the ${MAX_PREAMBLE_LENGTH} limit`,
829
+ excerpt(dropped, 120)
830
+ );
831
+ }
832
+ const body = lines.slice(first).map((line) => line.text).join("\n").trim();
833
+ return { body, preamble: dropped === "" ? void 0 : dropped };
834
+ };
835
+ var excerptAround = (text, index) => {
836
+ const start = Math.max(0, index - 30);
837
+ return excerpt(text.slice(start, Math.min(text.length, index + 90)), 200);
838
+ };
839
+ var findContentProblem = (text) => {
840
+ const trimmed = text.trim();
841
+ if (trimmed.length < MIN_DOCUMENT_LENGTH) {
842
+ return {
843
+ reason: `the response is ${trimmed.length} characters, below the ${MIN_DOCUMENT_LENGTH} minimum`,
844
+ excerpt: excerpt(trimmed, 120)
845
+ };
846
+ }
847
+ for (const rule of CONVERSATIONAL_RULES) {
848
+ const match = rule.pattern.exec(trimmed);
849
+ if (match !== null) {
850
+ return { reason: rule.reason, excerpt: excerptAround(trimmed, match.index) };
851
+ }
852
+ }
853
+ return void 0;
854
+ };
855
+ var assertDocumentContent = (providerName, text) => {
856
+ const problem = findContentProblem(text);
857
+ if (problem === void 0) return;
858
+ throw invalidContent(providerName, problem.reason, problem.excerpt);
859
+ };
860
+ var prepareDocument = (providerName, text) => {
861
+ const { body, preamble } = normalizeDocument(providerName, text);
862
+ assertDocumentContent(providerName, body);
863
+ return { body, droppedPreamble: preamble };
864
+ };
865
+
866
+ // src/generate/index.ts
867
+ var stringField = (cause, field) => {
868
+ if (typeof cause !== "object" || cause === null || !(field in cause)) {
869
+ return void 0;
870
+ }
871
+ const value = cause[field];
872
+ return typeof value === "string" ? value : void 0;
873
+ };
874
+ var generate = async (ctx) => {
875
+ const config = ctx.config ?? GlossicConfigSchema.parse({});
876
+ const scanned = await scan(ctx);
877
+ const { manifest } = scanned;
878
+ const generatedAt = manifest.generatedAt;
879
+ const root = manifest.workspace.root;
880
+ const cachePath = ctx.cachePath ?? path3.resolve(root, DEFAULT_CACHE_PATH);
881
+ const model = modelCacheKey(config);
882
+ const previous = await readCache(cachePath);
883
+ const previousEntries = indexCache(previous);
884
+ const allJobs = await buildJobs(manifest, config, root);
885
+ const matches = ctx.only === void 0 ? void 0 : picomatch(ctx.only);
886
+ const isSelected = (job) => matches === void 0 || matches(job.unit.id) || matches(job.unit.name) || matches(job.unit.path);
887
+ const selected = allJobs.filter(isSelected);
888
+ const filteredOut = allJobs.filter((job) => !isSelected(job)).map((job) => job.unit.id).sort(compareStrings);
889
+ const decisionContext = {
890
+ outDir: ctx.outDir,
891
+ model,
892
+ lang: config.lang,
893
+ force: ctx.force === true
894
+ };
895
+ const decisions = await Promise.all(
896
+ selected.map(async (job) => ({
897
+ job,
898
+ reason: await decide(job, previousEntries.get(job.unit.id), decisionContext)
899
+ }))
900
+ );
901
+ const plan = decisions.map(({ job, reason }) => ({
902
+ unitId: job.unit.id,
903
+ docPath: job.docPath,
904
+ files: job.unit.facts.base.files.length,
905
+ estimatedTokens: job.estimatedTokens,
906
+ reason,
907
+ regenerate: reason !== "cached"
908
+ })).sort((a, b) => compareStrings(a.unitId, b.unitId));
909
+ const sumTokens = (regenerate) => plan.filter((entry) => entry.regenerate === regenerate).reduce((sum, entry) => sum + entry.estimatedTokens, 0);
910
+ const estimatedTokens = sumTokens(true);
911
+ const savedTokens = sumTokens(false);
912
+ const fromCache = plan.filter((entry) => !entry.regenerate).length;
913
+ if (ctx.dryRun === true || ctx.provider === void 0) {
914
+ return {
915
+ manifest,
916
+ written: [],
917
+ plan,
918
+ failures: [],
919
+ warnings: [],
920
+ filteredOut,
921
+ estimatedTokens,
922
+ savedTokens,
923
+ generated: 0,
924
+ fromCache,
925
+ dryRun: true
926
+ };
927
+ }
928
+ const provider = ctx.provider;
929
+ const failures = [];
930
+ const warnings = [];
931
+ const summaries = /* @__PURE__ */ new Map();
932
+ const pending = decisions.filter(({ reason }) => reason !== "cached");
933
+ const total = decisions.length;
934
+ let completed = 0;
935
+ const report = (event) => ctx.onEvent?.(event);
936
+ const finished = (unitId, outcome, durationMs) => {
937
+ completed += 1;
938
+ report({ type: "unit-done", unitId, index: completed, total, outcome, durationMs });
939
+ };
940
+ for (const { job } of decisions.filter(({ reason }) => reason === "cached")) {
941
+ report({ type: "unit-start", unitId: job.unit.id, index: completed + 1, total });
942
+ finished(job.unit.id, "cached", 0);
943
+ }
944
+ const outcomes = await mapWithConcurrency(pending, config.concurrency, async ({ job }) => {
945
+ const startedAt = Date.now();
946
+ report({ type: "unit-start", unitId: job.unit.id, index: completed + 1, total });
947
+ try {
948
+ const completion = await withRetry(() => provider.complete(job.request), ctx.retry);
949
+ const prepared = prepareDocument(provider.name, completion.text);
950
+ if (prepared.droppedPreamble !== void 0) {
951
+ warnings.push({
952
+ unitId: job.unit.id,
953
+ message: `dropped ${prepared.droppedPreamble.length} characters before the first heading: ${excerpt(prepared.droppedPreamble, 120)}`
954
+ });
955
+ }
956
+ finished(job.unit.id, "generated", Date.now() - startedAt);
957
+ return { job, body: prepared.body };
958
+ } catch (cause) {
959
+ failures.push({
960
+ unitId: job.unit.id,
961
+ reason: cause instanceof Error ? cause.message : String(cause),
962
+ code: stringField(cause, "code"),
963
+ detail: stringField(cause, "detail")
964
+ });
965
+ finished(job.unit.id, "failed", Date.now() - startedAt);
966
+ return void 0;
967
+ }
968
+ });
969
+ const written = [];
970
+ const fresh = [];
971
+ for (const outcome of outcomes) {
972
+ if (outcome === void 0) continue;
973
+ await writeDoc(
974
+ ctx.outDir,
975
+ outcome.job.docPath,
976
+ renderUnitDoc({
977
+ unit: outcome.job.unit,
978
+ project: outcome.job.project,
979
+ body: outcome.body,
980
+ generatedAt
981
+ })
982
+ );
983
+ written.push(toPosix(outcome.job.docPath));
984
+ summaries.set(outcome.job.unit.id, outcome.body);
985
+ fresh.push({
986
+ unitId: outcome.job.unit.id,
987
+ unitHash: outcome.job.unit.hash,
988
+ promptVersion: PROMPT_VERSION,
989
+ model,
990
+ lang: config.lang,
991
+ outputPath: outcome.job.docPath,
992
+ generatedAt
993
+ });
994
+ }
995
+ const merged = new Map(previousEntries);
996
+ for (const entry of fresh) {
997
+ merged.set(entry.unitId, entry);
998
+ }
999
+ const liveUnitIds = new Set(manifest.units.map((unit) => unit.id));
1000
+ const nextCache = {
1001
+ version: emptyCache().version,
1002
+ entries: [...merged.values()].filter((entry) => liveUnitIds.has(entry.unitId))
1003
+ };
1004
+ await writeCache(nextCache, cachePath);
1005
+ await writeDoc(ctx.outDir, INDEX_DOC_PATH, renderIndexDoc({ manifest, generatedAt }));
1006
+ written.push(INDEX_DOC_PATH);
1007
+ const documentedUnits = manifest.units.map((unit) => {
1008
+ const summary = summaries.get(unit.id);
1009
+ return summary === void 0 ? unit : { ...unit, summary };
1010
+ });
1011
+ return {
1012
+ manifest: { ...manifest, units: documentedUnits },
1013
+ written: written.sort(compareStrings),
1014
+ plan,
1015
+ failures: failures.sort((a, b) => compareStrings(a.unitId, b.unitId)),
1016
+ warnings: warnings.sort((a, b) => compareStrings(a.unitId, b.unitId)),
1017
+ filteredOut,
1018
+ estimatedTokens,
1019
+ savedTokens,
1020
+ generated: fresh.length,
1021
+ fromCache,
1022
+ dryRun: false
1023
+ };
1024
+ };
1025
+
1026
+ // src/provider.ts
1027
+ var PROVIDER_PREFERENCE = ["claude-code", "anthropic"];
1028
+ var byPreference = (providers) => [...providers].sort((a, b) => {
1029
+ const rankA = PROVIDER_PREFERENCE.indexOf(a.name);
1030
+ const rankB = PROVIDER_PREFERENCE.indexOf(b.name);
1031
+ if (rankA !== rankB) {
1032
+ if (rankA === -1) return 1;
1033
+ if (rankB === -1) return -1;
1034
+ return rankA - rankB;
1035
+ }
1036
+ return compareStrings(a.name, b.name);
1037
+ });
1038
+ var probeProviders = async (providers) => Promise.all(
1039
+ byPreference(providers).map(async (provider) => ({
1040
+ name: provider.name,
1041
+ available: await provider.available().catch(() => false)
1042
+ }))
1043
+ );
1044
+ var resolveProvider = async (options) => {
1045
+ const known = options.providers.map((provider) => provider.name);
1046
+ const explicit = options.requested ?? options.config?.provider;
1047
+ if (explicit !== void 0 && explicit !== "") {
1048
+ const match = options.providers.find((provider) => provider.name === explicit);
1049
+ if (match === void 0) {
1050
+ throw new UnknownProviderError(explicit, known);
1051
+ }
1052
+ return match;
1053
+ }
1054
+ for (const provider of byPreference(options.providers)) {
1055
+ if (await provider.available().catch(() => false)) {
1056
+ return provider;
1057
+ }
1058
+ }
1059
+ throw new NoProviderAvailableError(known);
1060
+ };
1061
+
1062
+ // src/registry.ts
1063
+ var Registry = class {
1064
+ #items = /* @__PURE__ */ new Map();
1065
+ register(item) {
1066
+ this.#items.set(item.name, item);
1067
+ return this;
1068
+ }
1069
+ get(name) {
1070
+ return this.#items.get(name);
1071
+ }
1072
+ has(name) {
1073
+ return this.#items.has(name);
1074
+ }
1075
+ list() {
1076
+ return [...this.#items.values()];
1077
+ }
1078
+ get size() {
1079
+ return this.#items.size;
1080
+ }
1081
+ };
1082
+ var createAdapterRegistry = (adapters = []) => {
1083
+ const registry = new Registry();
1084
+ for (const adapter of adapters) {
1085
+ registry.register(adapter);
1086
+ }
1087
+ return registry;
1088
+ };
1089
+ var createProviderRegistry = (providers = []) => {
1090
+ const registry = new Registry();
1091
+ for (const provider of providers) {
1092
+ registry.register(provider);
1093
+ }
1094
+ return registry;
1095
+ };
1096
+
1097
+ // src/testing.ts
1098
+ var defaultDocument = (request, index) => [
1099
+ "## What it does",
1100
+ "",
1101
+ `Fake documentation number ${index} for ${String(request.metadata.unitId ?? "a unit")}.`,
1102
+ "",
1103
+ "## Responsibilities",
1104
+ "",
1105
+ "The unit owns its own behaviour and delegates everything else to its",
1106
+ "neighbours. Nothing here reaches outside the boundary it declares.",
1107
+ "",
1108
+ "## Public elements",
1109
+ "",
1110
+ "- Everything the unit exports, described in one line each.",
1111
+ "",
1112
+ "## Architectural decisions",
1113
+ "",
1114
+ "Dependencies point one way, errors are typed, and the ordering is total."
1115
+ ].join("\n");
1116
+ var createFakeProvider = (options = {}) => {
1117
+ const calls = [];
1118
+ return {
1119
+ name: options.name ?? "fake",
1120
+ calls,
1121
+ available: async () => options.available ?? true,
1122
+ complete: async (request) => {
1123
+ const index = calls.length;
1124
+ calls.push(request);
1125
+ const text = options.respond?.(request, index) ?? defaultDocument(request, index);
1126
+ return { text, model: "fake-model", usage: { inputTokens: 10, outputTokens: 5 } };
1127
+ }
1128
+ };
1129
+ };
1130
+
1131
+ // src/index.ts
1132
+ var CORE_VERSION = package_default.version;
1133
+
1134
+ export { CACHE_VERSION, CONFIG_FILENAMES, CORE_VERSION, CacheEntrySchema, CacheFileSchema, DEFAULT_CACHE_PATH, DEFAULT_MANIFEST_PATH, GROUPING_KEYS, INDEX_DOC_PATH, MAX_FILE_BYTES, MAX_PREAMBLE_LENGTH, MIN_DOCUMENT_LENGTH, NoProviderAvailableError, NotImplementedError, PROMPT_VERSION, PROVIDER_PREFERENCE, Registry, SYSTEM_PROMPT, UnknownProviderError, assertDocumentContent, backoffDelay, buildManifest, buildUnitPrompt, check, createAdapterRegistry, createFakeProvider, createProviderRegistry, emptyCache, estimateTokens, excerpt, findConfigFile, findContentProblem, generate, indexCache, loadProjectConfig, modelCacheKey, normalizeDocument, orderAdapters, pathExists, prepareDocument, probeProviders, readCache, readDocFrontmatter, readJson, readManifest, readText, readUnitSources, renderIndexDoc, renderUnitDoc, resolveConfig, resolveProvider, resolveWorkspace, scan, serializeCache, serializeManifest, unitDocPath, withRetry, writeCache, writeManifest };
1135
+ //# sourceMappingURL=index.js.map
1136
+ //# sourceMappingURL=index.js.map