@oa-sdk/spec-bundler 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.
@@ -0,0 +1,365 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { resolveBundleTargets } from "./resolver.js";
5
+ import { pruneUnreachableEntries } from "./tree-shaker.js";
6
+ import { extractDocumentAnchors } from "./link-rewriter.js";
7
+ import { readZipArchive } from "./archiver.js";
8
+ /**
9
+ * Calculates a deterministic master aggregate hash across all resolved file entries.
10
+ */
11
+ export function calculateAggregateHash(entries) {
12
+ const sorted = [...entries].sort((a, b) => a.bundleDestPath.localeCompare(b.bundleDestPath));
13
+ const hash = crypto.createHash("sha256");
14
+ for (const entry of sorted) {
15
+ hash.update(`${entry.bundleDestPath}:${entry.sha256}:${entry.size}\n`);
16
+ }
17
+ return hash.digest("hex");
18
+ }
19
+ /**
20
+ * Returns the lockfile path corresponding to a manifest file and optional topic profile.
21
+ * Supports consolidated lockfile mode (bundle.manifest.lock.json) or split topic lockfiles.
22
+ */
23
+ export function getLockfilePath(manifestPath, topic, lockfileMode) {
24
+ const dir = path.dirname(manifestPath);
25
+ let base = path.basename(manifestPath);
26
+ if (base.endsWith(".lock.json")) {
27
+ base = base.slice(0, -".lock.json".length);
28
+ }
29
+ else if (base.endsWith(".json")) {
30
+ base = base.slice(0, -".json".length);
31
+ }
32
+ if (topic && base.endsWith(`.${topic}`)) {
33
+ base = base.slice(0, -(`.${topic}`.length));
34
+ }
35
+ if (lockfileMode === "consolidated") {
36
+ return path.join(dir, `${base}.lock.json`);
37
+ }
38
+ const topicSuffix = topic ? `.${topic}` : "";
39
+ return path.join(dir, `${base}${topicSuffix}.lock.json`);
40
+ }
41
+ /**
42
+ * Reads and parses an existing lockfile. Returns null if missing or invalid.
43
+ */
44
+ export function readLockfile(lockfilePath) {
45
+ if (!fs.existsSync(lockfilePath))
46
+ return null;
47
+ try {
48
+ const raw = fs.readFileSync(lockfilePath, "utf8");
49
+ return JSON.parse(raw);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /**
56
+ * Generates an in-memory lockfile object without disk mutation.
57
+ */
58
+ export function generateLockfileData(manifest, entries, generatedAt) {
59
+ const aggregateHash = calculateAggregateHash(entries);
60
+ let totalSizeBytes = 0;
61
+ const entriesMap = {};
62
+ for (const entry of entries) {
63
+ totalSizeBytes += entry.size;
64
+ entriesMap[entry.bundleDestPath] = {
65
+ source: entry.sourceRelativePath,
66
+ sha256: entry.sha256,
67
+ size: entry.size,
68
+ mtime: entry.mtime,
69
+ };
70
+ }
71
+ return {
72
+ manifestVersion: manifest.version,
73
+ bundleName: manifest.name,
74
+ generatedAt: generatedAt ?? new Date().toISOString(),
75
+ aggregateHash,
76
+ filesCount: entries.length,
77
+ totalSizeBytes,
78
+ entries: entriesMap,
79
+ };
80
+ }
81
+ /**
82
+ * Writes a formatted lockfile to disk.
83
+ * If writing in consolidated mode with a topic, updates the topic entry inside bundle.manifest.lock.json.
84
+ */
85
+ export function writeLockfile(lockfilePath, manifest, entries, options) {
86
+ const mode = options?.lockfileMode ?? manifest.lockfileMode;
87
+ const topic = options?.topic;
88
+ if (mode === "consolidated" && topic) {
89
+ const consolidatedPath = getLockfilePath(lockfilePath, undefined, "consolidated");
90
+ let rootLockfile = readLockfile(consolidatedPath);
91
+ if (!rootLockfile) {
92
+ rootLockfile = {
93
+ manifestVersion: manifest.version,
94
+ bundleName: manifest.name.split(":")[0],
95
+ generatedAt: new Date().toISOString(),
96
+ aggregateHash: "",
97
+ filesCount: 0,
98
+ totalSizeBytes: 0,
99
+ entries: {},
100
+ topics: {},
101
+ };
102
+ }
103
+ if (!rootLockfile.topics) {
104
+ rootLockfile.topics = {};
105
+ }
106
+ const topicAggregateHash = calculateAggregateHash(entries);
107
+ let totalSizeBytes = 0;
108
+ const entriesMap = {};
109
+ for (const entry of entries) {
110
+ totalSizeBytes += entry.size;
111
+ entriesMap[entry.bundleDestPath] = {
112
+ source: entry.sourceRelativePath,
113
+ sha256: entry.sha256,
114
+ size: entry.size,
115
+ mtime: entry.mtime,
116
+ };
117
+ }
118
+ rootLockfile.topics[topic] = {
119
+ generatedAt: new Date().toISOString(),
120
+ aggregateHash: topicAggregateHash,
121
+ filesCount: entries.length,
122
+ totalSizeBytes,
123
+ entries: entriesMap,
124
+ };
125
+ fs.mkdirSync(path.dirname(consolidatedPath), { recursive: true });
126
+ fs.writeFileSync(consolidatedPath, JSON.stringify(rootLockfile, null, 2), "utf8");
127
+ return { ...rootLockfile, aggregateHash: topicAggregateHash };
128
+ }
129
+ const lockfile = generateLockfileData(manifest, entries);
130
+ fs.mkdirSync(path.dirname(lockfilePath), { recursive: true });
131
+ fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2), "utf8");
132
+ return lockfile;
133
+ }
134
+ /**
135
+ * Evaluates whether the bundle is fresh or has drifted compared to the lockfile and output file.
136
+ * Supports consolidated lockfile reading and semantic structural diff computing.
137
+ */
138
+ export function checkFreshness(manifest, baseDir, lockfilePath, options) {
139
+ let { entries: currentMap } = resolveBundleTargets(manifest, baseDir);
140
+ if (manifest.treeShake?.enabled) {
141
+ const treeShakeResult = pruneUnreachableEntries(currentMap, manifest.treeShake, baseDir);
142
+ currentMap = treeShakeResult.reachableEntries;
143
+ }
144
+ const currentEntries = Array.from(currentMap.values());
145
+ const currentAggregateHash = calculateAggregateHash(currentEntries);
146
+ const topic = options?.topic;
147
+ let lockfile = readLockfile(lockfilePath);
148
+ // If topic specified and topic-specific lockfile was not found or lacks entries, check consolidated lockfile
149
+ if ((!lockfile || !lockfile.entries || Object.keys(lockfile.entries).length === 0) && topic) {
150
+ const consolidatedPath = getLockfilePath(lockfilePath, undefined, "consolidated");
151
+ const consolidatedLock = readLockfile(consolidatedPath);
152
+ if (consolidatedLock?.topics?.[topic]) {
153
+ lockfile = {
154
+ manifestVersion: consolidatedLock.manifestVersion,
155
+ bundleName: `${consolidatedLock.bundleName}:${topic}`,
156
+ generatedAt: consolidatedLock.topics[topic].generatedAt,
157
+ aggregateHash: consolidatedLock.topics[topic].aggregateHash,
158
+ filesCount: consolidatedLock.topics[topic].filesCount,
159
+ totalSizeBytes: consolidatedLock.topics[topic].totalSizeBytes,
160
+ entries: consolidatedLock.topics[topic].entries,
161
+ };
162
+ }
163
+ }
164
+ else if (topic && lockfile?.topics?.[topic]) {
165
+ const topicData = lockfile.topics[topic];
166
+ lockfile = {
167
+ manifestVersion: lockfile.manifestVersion,
168
+ bundleName: `${lockfile.bundleName}:${topic}`,
169
+ generatedAt: topicData.generatedAt,
170
+ aggregateHash: topicData.aggregateHash,
171
+ filesCount: topicData.filesCount,
172
+ totalSizeBytes: topicData.totalSizeBytes,
173
+ entries: topicData.entries,
174
+ };
175
+ }
176
+ if (!lockfile) {
177
+ const result = {
178
+ isFresh: false,
179
+ currentAggregateHash,
180
+ lockAggregateHash: undefined,
181
+ added: Array.from(currentMap.keys()),
182
+ removed: [],
183
+ modified: [],
184
+ };
185
+ if (options?.diff) {
186
+ result.semanticDiff = computeSemanticDiff(manifest, baseDir, currentEntries, null, topic);
187
+ }
188
+ return result;
189
+ }
190
+ const added = [];
191
+ const removed = [];
192
+ const modified = [];
193
+ const lockKeys = new Set(Object.keys(lockfile.entries));
194
+ const currentKeys = new Set(currentMap.keys());
195
+ for (const key of currentKeys) {
196
+ if (!lockKeys.has(key)) {
197
+ added.push(key);
198
+ }
199
+ else {
200
+ const lockEntry = lockfile.entries[key];
201
+ const currentEntry = currentMap.get(key);
202
+ if (lockEntry.sha256 !== currentEntry.sha256) {
203
+ modified.push(key);
204
+ }
205
+ }
206
+ }
207
+ for (const key of lockKeys) {
208
+ if (!currentKeys.has(key)) {
209
+ removed.push(key);
210
+ }
211
+ }
212
+ const outputPath = path.resolve(baseDir, manifest.output);
213
+ const outputExists = fs.existsSync(outputPath);
214
+ const isFresh = outputExists &&
215
+ currentAggregateHash === lockfile.aggregateHash &&
216
+ added.length === 0 &&
217
+ removed.length === 0 &&
218
+ modified.length === 0;
219
+ const result = {
220
+ isFresh,
221
+ currentAggregateHash,
222
+ lockAggregateHash: lockfile.aggregateHash,
223
+ added,
224
+ removed,
225
+ modified,
226
+ };
227
+ if (options?.diff && !isFresh) {
228
+ result.semanticDiff = computeSemanticDiff(manifest, baseDir, currentEntries, lockfile, topic);
229
+ }
230
+ return result;
231
+ }
232
+ /**
233
+ * Computes semantic structural diff between source files on disk and the built bundle/lockfile.
234
+ * Detects modified anchors in Markdown and symbols in TypeSpec.
235
+ */
236
+ export function computeSemanticDiff(manifest, baseDir, currentEntries, lockfile, topic) {
237
+ const currentMap = new Map(currentEntries.map((e) => [e.bundleDestPath, e]));
238
+ let lockEntries = {};
239
+ if (lockfile) {
240
+ if (topic && lockfile.topics?.[topic]) {
241
+ lockEntries = lockfile.topics[topic].entries;
242
+ }
243
+ else {
244
+ lockEntries = lockfile.entries ?? {};
245
+ }
246
+ }
247
+ const currentKeys = new Set(currentMap.keys());
248
+ const lockKeys = new Set(Object.keys(lockEntries));
249
+ const addedFiles = [];
250
+ const removedFiles = [];
251
+ const modifiedFiles = [];
252
+ for (const key of currentKeys) {
253
+ if (!lockKeys.has(key)) {
254
+ addedFiles.push(key);
255
+ }
256
+ else if (lockEntries[key].sha256 !== currentMap.get(key).sha256) {
257
+ const entry = currentMap.get(key);
258
+ const diff = analyzeFileStructuralDiff(entry, baseDir, manifest);
259
+ modifiedFiles.push(diff);
260
+ }
261
+ }
262
+ for (const key of lockKeys) {
263
+ if (!currentKeys.has(key)) {
264
+ removedFiles.push(key);
265
+ }
266
+ }
267
+ return {
268
+ addedFiles,
269
+ removedFiles,
270
+ modifiedFiles,
271
+ };
272
+ }
273
+ function analyzeFileStructuralDiff(currentEntry, baseDir, manifest) {
274
+ const ext = path.extname(currentEntry.sourceAbsolutePath).toLowerCase();
275
+ let currentContent = "";
276
+ try {
277
+ currentContent = fs.readFileSync(currentEntry.sourceAbsolutePath, "utf8");
278
+ }
279
+ catch { }
280
+ let previousContent = null;
281
+ const outputPath = path.resolve(baseDir, manifest.output);
282
+ if (fs.existsSync(outputPath)) {
283
+ if (manifest.format === "directory" || !manifest.output.endsWith(".zip")) {
284
+ const prevFilePath = path.join(outputPath, currentEntry.bundleDestPath);
285
+ if (fs.existsSync(prevFilePath)) {
286
+ try {
287
+ previousContent = fs.readFileSync(prevFilePath, "utf8");
288
+ }
289
+ catch { }
290
+ }
291
+ }
292
+ else {
293
+ try {
294
+ const zipBuffer = fs.readFileSync(outputPath);
295
+ const archive = readZipArchive(zipBuffer);
296
+ const fileBuffer = archive.get(currentEntry.bundleDestPath);
297
+ if (fileBuffer) {
298
+ previousContent = fileBuffer.toString("utf8");
299
+ }
300
+ }
301
+ catch { }
302
+ }
303
+ }
304
+ if (ext === ".md" || ext === ".mdx") {
305
+ const currentAnchorSet = extractDocumentAnchors(currentContent);
306
+ const currentAnchors = Array.from(currentAnchorSet.explicit.size > 0
307
+ ? currentAnchorSet.explicit
308
+ : currentAnchorSet.slugs);
309
+ const prevAnchorSet = previousContent !== null ? extractDocumentAnchors(previousContent) : null;
310
+ const prevAnchors = prevAnchorSet
311
+ ? Array.from(prevAnchorSet.explicit.size > 0
312
+ ? prevAnchorSet.explicit
313
+ : prevAnchorSet.slugs)
314
+ : [];
315
+ const addedAnchors = currentAnchors.filter((a) => !prevAnchors.includes(a));
316
+ const removedAnchors = prevAnchors.filter((a) => !currentAnchors.includes(a));
317
+ const lineDelta = previousContent !== null
318
+ ? currentContent.split("\n").length -
319
+ previousContent.split("\n").length
320
+ : undefined;
321
+ return {
322
+ file: currentEntry.bundleDestPath,
323
+ type: "markdown",
324
+ addedAnchors: addedAnchors.length > 0 ? addedAnchors : undefined,
325
+ removedAnchors: removedAnchors.length > 0 ? removedAnchors : undefined,
326
+ lineDelta,
327
+ };
328
+ }
329
+ if (ext === ".tsp") {
330
+ const extractSymbols = (code) => {
331
+ const symbols = [];
332
+ const regex = /(?:model|scalar|interface|enum|op)\s+([A-Za-z0-9_]+)/g;
333
+ let m;
334
+ while ((m = regex.exec(code)) !== null) {
335
+ symbols.push(m[1]);
336
+ }
337
+ return symbols;
338
+ };
339
+ const currentSymbols = extractSymbols(currentContent);
340
+ const prevSymbols = previousContent !== null ? extractSymbols(previousContent) : [];
341
+ const addedSymbols = currentSymbols.filter((s) => !prevSymbols.includes(s));
342
+ const removedSymbols = prevSymbols.filter((s) => !currentSymbols.includes(s));
343
+ const lineDelta = previousContent !== null
344
+ ? currentContent.split("\n").length -
345
+ previousContent.split("\n").length
346
+ : undefined;
347
+ return {
348
+ file: currentEntry.bundleDestPath,
349
+ type: "typespec",
350
+ addedSymbols: addedSymbols.length > 0 ? addedSymbols : undefined,
351
+ removedSymbols: removedSymbols.length > 0 ? removedSymbols : undefined,
352
+ lineDelta,
353
+ };
354
+ }
355
+ const lineDelta = previousContent !== null
356
+ ? currentContent.split("\n").length -
357
+ previousContent.split("\n").length
358
+ : undefined;
359
+ return {
360
+ file: currentEntry.bundleDestPath,
361
+ type: "other",
362
+ lineDelta,
363
+ };
364
+ }
365
+ //# sourceMappingURL=freshness.js.map
@@ -0,0 +1,71 @@
1
+ import type { BundleManifest, FreshnessResult, LinkDiagnostic, PackResult, BundlePathResolution } from "./types.js";
2
+ import type { PackOptions, DependencyTreeNode, ReachabilityHop, DocumentCategory } from "./types.js";
3
+ export * from "./types.js";
4
+ export * from "./resolver.js";
5
+ export * from "./link-rewriter.js";
6
+ export * from "./archiver.js";
7
+ export * from "./freshness.js";
8
+ export * from "./tree-shaker.js";
9
+ export * from "./schema.js";
10
+ export * from "./topic.js";
11
+ export * from "./section-slicer.js";
12
+ export * from "./context-generator.js";
13
+ export * from "./category-parser.js";
14
+ /**
15
+ * Loads and validates a bundle manifest file from disk.
16
+ */
17
+ export declare function loadManifest(manifestPath: string): BundleManifest;
18
+ /**
19
+ * Executes packaging: target resolution, deduplication, markdown link rewriting,
20
+ * ZIP creation, and lockfile synchronization. Supports dryRun preview.
21
+ */
22
+ export declare function createBundle(manifestPath: string, options?: PackOptions): PackResult;
23
+ /**
24
+ * Inspects a bundle manifest, returning its dependency reachability tree, size breakdown,
25
+ * and provenance lineage for all files.
26
+ */
27
+ export declare function inspectBundle(manifestPath: string, options?: {
28
+ topic?: string;
29
+ }): {
30
+ manifest: BundleManifest;
31
+ tree: DependencyTreeNode[];
32
+ asciiTree: string;
33
+ reachableCount: number;
34
+ eliminatedCount: number;
35
+ totalSizeBytes: number;
36
+ warnings: string[];
37
+ lineage: Map<string, ReachabilityHop>;
38
+ boundaryTerminals?: string[];
39
+ boundaryEdges?: Map<string, Array<{
40
+ target: string;
41
+ rawRef: string;
42
+ }>>;
43
+ upstreamNodes?: string[];
44
+ nodeCategories?: Map<string, DocumentCategory>;
45
+ };
46
+ /**
47
+ * Verifies integrity, dead links, and drift for a manifest without packaging.
48
+ */
49
+ export declare function verifyBundle(manifestPath: string, options?: {
50
+ checkAnchors?: boolean;
51
+ topic?: string;
52
+ }): {
53
+ freshness: FreshnessResult;
54
+ deadLinks: LinkDiagnostic[];
55
+ warnings: string[];
56
+ };
57
+ /**
58
+ * Extracts an archive file into target destination directory.
59
+ */
60
+ export declare function unpackBundle(archivePath: string, destDir: string): {
61
+ extractedCount: number;
62
+ files: string[];
63
+ };
64
+ /**
65
+ * Resolves a source path, relative reference, or TypeSpec citation to its
66
+ * exact coordinate within a packaged bundle archive or extracted directory.
67
+ */
68
+ export declare function resolveBundlePath(archiveInput: string | Buffer, queryReference: string, options?: {
69
+ baseDir?: string;
70
+ }): BundlePathResolution;
71
+ //# sourceMappingURL=index.d.ts.map