@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.
- package/LICENSE +201 -0
- package/README.md +240 -0
- package/dist/archiver.d.ts +22 -0
- package/dist/archiver.js +155 -0
- package/dist/category-parser.d.ts +15 -0
- package/dist/category-parser.js +115 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +760 -0
- package/dist/context-generator.d.ts +14 -0
- package/dist/context-generator.js +297 -0
- package/dist/freshness.d.ts +41 -0
- package/dist/freshness.js +365 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +705 -0
- package/dist/link-rewriter.d.ts +65 -0
- package/dist/link-rewriter.js +777 -0
- package/dist/resolver.d.ts +27 -0
- package/dist/resolver.js +276 -0
- package/dist/schema.d.ts +563 -0
- package/dist/schema.js +670 -0
- package/dist/section-slicer.d.ts +12 -0
- package/dist/section-slicer.js +114 -0
- package/dist/topic.d.ts +62 -0
- package/dist/topic.js +262 -0
- package/dist/tree-shaker.d.ts +40 -0
- package/dist/tree-shaker.js +523 -0
- package/dist/types.d.ts +369 -0
- package/dist/types.js +2 -0
- package/package.json +29 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
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 { rewriteMarkdownContent, rewriteTypeSpecContent, verifyBundleLinkIntegrity, findClosestFile, } from "./link-rewriter.js";
|
|
6
|
+
import { createZipArchive, extractZipArchive, readZipArchive, } from "./archiver.js";
|
|
7
|
+
import { checkFreshness, getLockfilePath, writeLockfile, calculateAggregateHash, generateLockfileData, } from "./freshness.js";
|
|
8
|
+
import { pruneUnreachableEntries, buildDependencyTree, renderAsciiTree, } from "./tree-shaker.js";
|
|
9
|
+
import { validateManifestSchema } from "./schema.js";
|
|
10
|
+
import { resolveTopicManifest } from "./topic.js";
|
|
11
|
+
import { generateTopicContextMarkdown } from "./context-generator.js";
|
|
12
|
+
import { parseDocumentCategory, extractDocumentFidelity } from "./category-parser.js";
|
|
13
|
+
export * from "./types.js";
|
|
14
|
+
export * from "./resolver.js";
|
|
15
|
+
export * from "./link-rewriter.js";
|
|
16
|
+
export * from "./archiver.js";
|
|
17
|
+
export * from "./freshness.js";
|
|
18
|
+
export * from "./tree-shaker.js";
|
|
19
|
+
export * from "./schema.js";
|
|
20
|
+
export * from "./topic.js";
|
|
21
|
+
export * from "./section-slicer.js";
|
|
22
|
+
export * from "./context-generator.js";
|
|
23
|
+
export * from "./category-parser.js";
|
|
24
|
+
/**
|
|
25
|
+
* Loads and validates a bundle manifest file from disk.
|
|
26
|
+
*/
|
|
27
|
+
export function loadManifest(manifestPath) {
|
|
28
|
+
const absPath = path.resolve(process.cwd(), manifestPath);
|
|
29
|
+
if (!fs.existsSync(absPath)) {
|
|
30
|
+
throw new Error(`Manifest file not found: ${manifestPath}`);
|
|
31
|
+
}
|
|
32
|
+
const raw = fs.readFileSync(absPath, "utf8");
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new Error(`JSON syntax error in manifest ${manifestPath}: ${err.message}`);
|
|
39
|
+
}
|
|
40
|
+
const errors = validateManifestSchema(parsed);
|
|
41
|
+
if (errors.length > 0) {
|
|
42
|
+
throw new Error(`Invalid manifest format in ${manifestPath}:\n - ${errors.join("\n - ")}`);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Executes packaging: target resolution, deduplication, markdown link rewriting,
|
|
48
|
+
* ZIP creation, and lockfile synchronization. Supports dryRun preview.
|
|
49
|
+
*/
|
|
50
|
+
export function createBundle(manifestPath, options = {}) {
|
|
51
|
+
const absManifest = path.resolve(process.cwd(), manifestPath);
|
|
52
|
+
const baseDir = path.dirname(absManifest);
|
|
53
|
+
const rawManifest = loadManifest(manifestPath);
|
|
54
|
+
const manifest = options.topic
|
|
55
|
+
? resolveTopicManifest(rawManifest, options.topic)
|
|
56
|
+
: rawManifest;
|
|
57
|
+
if (options.treeShake !== undefined) {
|
|
58
|
+
if (!manifest.treeShake) {
|
|
59
|
+
manifest.treeShake = { enabled: options.treeShake, entrypoints: [] };
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
manifest.treeShake = { ...manifest.treeShake, enabled: options.treeShake };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const effectiveLockfileMode = options.lockfileMode ?? manifest.lockfileMode;
|
|
66
|
+
const lockfilePath = getLockfilePath(absManifest, options.topic, effectiveLockfileMode);
|
|
67
|
+
// Freshness check: if not forced and not dry-run, skip if already fresh.
|
|
68
|
+
// In dryRun mode, always execute simulation to provide full prediction records.
|
|
69
|
+
if (!options.force && !options.dryRun) {
|
|
70
|
+
const freshness = checkFreshness(manifest, baseDir, lockfilePath, {
|
|
71
|
+
topic: options.topic,
|
|
72
|
+
diff: options.diff,
|
|
73
|
+
lockfileMode: effectiveLockfileMode,
|
|
74
|
+
});
|
|
75
|
+
if (freshness.isFresh) {
|
|
76
|
+
const outputPath = path.resolve(baseDir, manifest.output);
|
|
77
|
+
const stat = fs.existsSync(outputPath) ? fs.statSync(outputPath) : { size: 0 };
|
|
78
|
+
return {
|
|
79
|
+
outputPath,
|
|
80
|
+
filesCount: 0,
|
|
81
|
+
totalSizeBytes: stat.size,
|
|
82
|
+
aggregateHash: freshness.currentAggregateHash,
|
|
83
|
+
diagnostics: [],
|
|
84
|
+
skipped: true,
|
|
85
|
+
topic: options.topic,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 1. Resolve targets & deduplicate
|
|
90
|
+
const { entries, warnings } = resolveBundleTargets(manifest, baseDir);
|
|
91
|
+
// 1.5. Tree-shaking reachability pruning
|
|
92
|
+
let activeEntries = entries;
|
|
93
|
+
let eliminatedFiles = [];
|
|
94
|
+
let boundaryTerminals = [];
|
|
95
|
+
let upstreamNodes = [];
|
|
96
|
+
let nodeCategories = new Map();
|
|
97
|
+
if (manifest.treeShake?.enabled) {
|
|
98
|
+
const treeShakeResult = pruneUnreachableEntries(entries, manifest.treeShake, baseDir);
|
|
99
|
+
activeEntries = treeShakeResult.reachableEntries;
|
|
100
|
+
eliminatedFiles = treeShakeResult.eliminatedEntries.map((e) => e.bundleDestPath);
|
|
101
|
+
boundaryTerminals = treeShakeResult.boundaryTerminals ?? [];
|
|
102
|
+
upstreamNodes = treeShakeResult.upstreamNodes ?? [];
|
|
103
|
+
nodeCategories = treeShakeResult.nodeCategories ?? new Map();
|
|
104
|
+
warnings.push(...treeShakeResult.warnings);
|
|
105
|
+
}
|
|
106
|
+
const resolvedList = Array.from(activeEntries.values());
|
|
107
|
+
// 2. Build reverse lookup map: absoluteSourcePath -> bundleDestPath
|
|
108
|
+
const sourceAbsToBundleDest = new Map();
|
|
109
|
+
for (const entry of resolvedList) {
|
|
110
|
+
sourceAbsToBundleDest.set(path.resolve(entry.sourceAbsolutePath), entry.bundleDestPath);
|
|
111
|
+
if (!nodeCategories.has(entry.bundleDestPath)) {
|
|
112
|
+
const cat = parseDocumentCategory("", entry.sourceAbsolutePath);
|
|
113
|
+
nodeCategories.set(entry.bundleDestPath, cat);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// 3. Process contents (apply markdown link rewriting)
|
|
117
|
+
const allDiagnostics = [];
|
|
118
|
+
const processedContents = new Map();
|
|
119
|
+
for (const entry of resolvedList) {
|
|
120
|
+
let rawBuffer = fs.readFileSync(entry.sourceAbsolutePath);
|
|
121
|
+
if ((entry.bundleDestPath.endsWith(".md") || entry.bundleDestPath.endsWith(".mdx"))) {
|
|
122
|
+
let rawText = rawBuffer.toString("utf8");
|
|
123
|
+
// Apply category parsing and section/fidelity slicing (skipping ambient context docs)
|
|
124
|
+
const isContextDoc = entry.bundleDestPath.startsWith("context/") ||
|
|
125
|
+
entry.bundleDestPath.startsWith("_context/");
|
|
126
|
+
if (!isContextDoc) {
|
|
127
|
+
const category = nodeCategories.get(entry.bundleDestPath) ??
|
|
128
|
+
parseDocumentCategory(rawText, entry.sourceAbsolutePath);
|
|
129
|
+
nodeCategories.set(entry.bundleDestPath, category);
|
|
130
|
+
const fidelityMap = manifest.scope?.categoryFidelity ?? manifest.treeShake?.categoryFidelity;
|
|
131
|
+
const isUpstream = upstreamNodes.includes(entry.bundleDestPath);
|
|
132
|
+
const fidelity = (isUpstream && fidelityMap?.["upstream"]) ||
|
|
133
|
+
fidelityMap?.[category] ||
|
|
134
|
+
fidelityMap?.["default"] ||
|
|
135
|
+
"full";
|
|
136
|
+
if (fidelity !== "full" || (manifest.scope?.sections && manifest.scope.sections.length > 0)) {
|
|
137
|
+
const fidelityResult = extractDocumentFidelity(rawText, category, fidelity, manifest.scope?.sections);
|
|
138
|
+
if (fidelityResult.isModified) {
|
|
139
|
+
rawText = fidelityResult.content;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (manifest.linkResolution?.enabled !== false) {
|
|
144
|
+
const { rewritten, diagnostics } = rewriteMarkdownContent(rawText, entry.sourceAbsolutePath, entry.bundleDestPath, sourceAbsToBundleDest, baseDir, manifest.linkResolution);
|
|
145
|
+
allDiagnostics.push(...diagnostics);
|
|
146
|
+
rawText = rewritten;
|
|
147
|
+
}
|
|
148
|
+
rawBuffer = Buffer.from(rawText, "utf8");
|
|
149
|
+
}
|
|
150
|
+
else if (entry.bundleDestPath.endsWith(".tsp") &&
|
|
151
|
+
manifest.linkResolution?.enabled !== false) {
|
|
152
|
+
const rawText = rawBuffer.toString("utf8");
|
|
153
|
+
const { rewritten, diagnostics } = rewriteTypeSpecContent(rawText, entry.sourceAbsolutePath, entry.bundleDestPath, sourceAbsToBundleDest, baseDir, manifest.linkResolution);
|
|
154
|
+
allDiagnostics.push(...diagnostics);
|
|
155
|
+
rawBuffer = Buffer.from(rewritten, "utf8");
|
|
156
|
+
}
|
|
157
|
+
processedContents.set(entry.bundleDestPath, rawBuffer);
|
|
158
|
+
}
|
|
159
|
+
// 3.5. Synthesize CONTEXT.md at bundle root if context is declared
|
|
160
|
+
if (manifest.context && manifest.context.generateContextDoc !== false) {
|
|
161
|
+
const topicName = options.topic ?? manifest.name;
|
|
162
|
+
const contextContent = generateTopicContextMarkdown(topicName, {
|
|
163
|
+
phase: manifest.phase,
|
|
164
|
+
author: manifest.author,
|
|
165
|
+
description: manifest.description,
|
|
166
|
+
targets: manifest.targets,
|
|
167
|
+
scope: manifest.scope,
|
|
168
|
+
context: manifest.context,
|
|
169
|
+
treeShake: manifest.treeShake,
|
|
170
|
+
}, resolvedList, {
|
|
171
|
+
boundaryTerminals,
|
|
172
|
+
upstreamNodes,
|
|
173
|
+
nodeCategories,
|
|
174
|
+
});
|
|
175
|
+
processedContents.set("CONTEXT.md", Buffer.from(contextContent, "utf8"));
|
|
176
|
+
}
|
|
177
|
+
// 4. Dead link post-verification
|
|
178
|
+
const deadLinkSeverity = manifest.linkResolution?.unbundledPolicy === "warn"
|
|
179
|
+
? "warning"
|
|
180
|
+
: manifest.linkResolution?.unbundledPolicy === "error"
|
|
181
|
+
? "error"
|
|
182
|
+
: "warning";
|
|
183
|
+
const deadLinkDiags = verifyBundleLinkIntegrity(processedContents, {
|
|
184
|
+
severity: deadLinkSeverity,
|
|
185
|
+
unbundledPolicy: manifest.linkResolution?.unbundledPolicy,
|
|
186
|
+
});
|
|
187
|
+
const existingDiagKeys = new Set(allDiagnostics.map((d) => `${d.file}:${d.line}:${d.column ?? 1}:${d.originalHref}`));
|
|
188
|
+
for (const diag of deadLinkDiags) {
|
|
189
|
+
const key = `${diag.file}:${diag.line}:${diag.column ?? 1}:${diag.originalHref}`;
|
|
190
|
+
if (!existingDiagKeys.has(key)) {
|
|
191
|
+
allDiagnostics.push(diag);
|
|
192
|
+
existingDiagKeys.add(key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 5. Output packaging calculation
|
|
196
|
+
const outputPath = path.resolve(baseDir, manifest.output);
|
|
197
|
+
const format = manifest.format ?? "zip";
|
|
198
|
+
let totalBytes = 0;
|
|
199
|
+
for (const buf of processedContents.values()) {
|
|
200
|
+
totalBytes += buf.length;
|
|
201
|
+
}
|
|
202
|
+
const aggregateHash = calculateAggregateHash(resolvedList);
|
|
203
|
+
const predictedLockfile = generateLockfileData(manifest, resolvedList);
|
|
204
|
+
const freshness = checkFreshness(manifest, baseDir, lockfilePath, {
|
|
205
|
+
topic: options.topic,
|
|
206
|
+
diff: options.diff,
|
|
207
|
+
lockfileMode: effectiveLockfileMode,
|
|
208
|
+
});
|
|
209
|
+
const plannedEntries = resolvedList.map((entry) => ({
|
|
210
|
+
source: entry.sourceRelativePath,
|
|
211
|
+
dest: entry.bundleDestPath,
|
|
212
|
+
size: processedContents.get(entry.bundleDestPath)?.length ?? entry.size,
|
|
213
|
+
sha256: entry.sha256,
|
|
214
|
+
}));
|
|
215
|
+
if (processedContents.has("CONTEXT.md")) {
|
|
216
|
+
const contextBuf = processedContents.get("CONTEXT.md");
|
|
217
|
+
const contextSha = crypto.createHash("sha256").update(contextBuf).digest("hex");
|
|
218
|
+
plannedEntries.push({
|
|
219
|
+
source: "(synthesized)",
|
|
220
|
+
dest: "CONTEXT.md",
|
|
221
|
+
size: contextBuf.length,
|
|
222
|
+
sha256: contextSha,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const rewrites = allDiagnostics
|
|
226
|
+
.filter((d) => d.severity === "info" &&
|
|
227
|
+
d.rewrittenHref !== undefined &&
|
|
228
|
+
d.originalHref !== d.rewrittenHref)
|
|
229
|
+
.map((d) => ({
|
|
230
|
+
file: d.file,
|
|
231
|
+
line: d.line,
|
|
232
|
+
column: d.column ?? 1,
|
|
233
|
+
originalHref: d.originalHref,
|
|
234
|
+
rewrittenHref: d.rewrittenHref,
|
|
235
|
+
targetBundleDest: d.targetBundleDest,
|
|
236
|
+
linkKind: d.linkKind ?? "inline",
|
|
237
|
+
}));
|
|
238
|
+
const unbundledDiagnostics = allDiagnostics.filter((d) => d.message.includes("Unbundled") ||
|
|
239
|
+
d.message.includes("unbundled") ||
|
|
240
|
+
d.message.includes("not part of this bundle"));
|
|
241
|
+
const direction = manifest.treeShake?.direction ?? manifest.scope?.direction ?? "downstream";
|
|
242
|
+
const categoryBreakdown = {};
|
|
243
|
+
for (const entry of resolvedList) {
|
|
244
|
+
const cat = nodeCategories.get(entry.bundleDestPath) ?? "feature";
|
|
245
|
+
categoryBreakdown[cat] = (categoryBreakdown[cat] ?? 0) + 1;
|
|
246
|
+
}
|
|
247
|
+
if (options.dryRun) {
|
|
248
|
+
return {
|
|
249
|
+
outputPath,
|
|
250
|
+
filesCount: processedContents.size,
|
|
251
|
+
totalSizeBytes: totalBytes,
|
|
252
|
+
aggregateHash,
|
|
253
|
+
diagnostics: allDiagnostics,
|
|
254
|
+
skipped: false,
|
|
255
|
+
dryRun: true,
|
|
256
|
+
topic: options.topic,
|
|
257
|
+
phase: manifest.phase,
|
|
258
|
+
author: manifest.author,
|
|
259
|
+
eliminatedFiles: eliminatedFiles.length > 0 ? eliminatedFiles : undefined,
|
|
260
|
+
boundaryTerminals: boundaryTerminals.length > 0 ? boundaryTerminals : undefined,
|
|
261
|
+
upstreamNodes: upstreamNodes.length > 0 ? upstreamNodes : undefined,
|
|
262
|
+
direction,
|
|
263
|
+
categoryBreakdown,
|
|
264
|
+
plannedEntries,
|
|
265
|
+
rewrites,
|
|
266
|
+
predictedLockfile,
|
|
267
|
+
freshness,
|
|
268
|
+
unbundledDiagnostics,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
// Only create directory and write files when NOT dryRun
|
|
272
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
273
|
+
if (format === "zip") {
|
|
274
|
+
const zipEntries = [];
|
|
275
|
+
for (const [bundlePath, buf] of processedContents.entries()) {
|
|
276
|
+
zipEntries.push({
|
|
277
|
+
path: bundlePath,
|
|
278
|
+
data: buf,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// Embed manifest BOM with bidirectional path mapping
|
|
282
|
+
const sourceToBundle = {};
|
|
283
|
+
const bundleToSource = {};
|
|
284
|
+
for (const entry of resolvedList) {
|
|
285
|
+
const src = entry.sourceRelativePath.replace(/\\/g, "/");
|
|
286
|
+
const dst = entry.bundleDestPath.replace(/\\/g, "/");
|
|
287
|
+
sourceToBundle[src] = dst;
|
|
288
|
+
bundleToSource[dst] = src;
|
|
289
|
+
}
|
|
290
|
+
if (processedContents.has("CONTEXT.md")) {
|
|
291
|
+
sourceToBundle["(synthesized)"] = "CONTEXT.md";
|
|
292
|
+
bundleToSource["CONTEXT.md"] = "(synthesized)";
|
|
293
|
+
}
|
|
294
|
+
const bomManifest = {
|
|
295
|
+
manifestName: manifest.name,
|
|
296
|
+
manifestVersion: manifest.version,
|
|
297
|
+
topic: options.topic,
|
|
298
|
+
topicPhase: manifest.phase,
|
|
299
|
+
topicAuthor: manifest.author,
|
|
300
|
+
topicContext: manifest.context,
|
|
301
|
+
topicScope: manifest.scope,
|
|
302
|
+
direction,
|
|
303
|
+
categoryBreakdown,
|
|
304
|
+
generatedAt: new Date().toISOString(),
|
|
305
|
+
fileCount: zipEntries.length,
|
|
306
|
+
files: zipEntries.map((e) => e.path),
|
|
307
|
+
sourceToBundle,
|
|
308
|
+
bundleToSource,
|
|
309
|
+
};
|
|
310
|
+
zipEntries.push({
|
|
311
|
+
path: "META-INF/bundle.manifest.json",
|
|
312
|
+
data: Buffer.from(JSON.stringify(bomManifest, null, 2), "utf8"),
|
|
313
|
+
});
|
|
314
|
+
const zipBuffer = createZipArchive(zipEntries);
|
|
315
|
+
fs.writeFileSync(outputPath, zipBuffer);
|
|
316
|
+
}
|
|
317
|
+
else if (format === "directory") {
|
|
318
|
+
const sourceToBundle = {};
|
|
319
|
+
const bundleToSource = {};
|
|
320
|
+
for (const entry of resolvedList) {
|
|
321
|
+
const src = entry.sourceRelativePath.replace(/\\/g, "/");
|
|
322
|
+
const dst = entry.bundleDestPath.replace(/\\/g, "/");
|
|
323
|
+
sourceToBundle[src] = dst;
|
|
324
|
+
bundleToSource[dst] = src;
|
|
325
|
+
}
|
|
326
|
+
if (processedContents.has("CONTEXT.md")) {
|
|
327
|
+
sourceToBundle["(synthesized)"] = "CONTEXT.md";
|
|
328
|
+
bundleToSource["CONTEXT.md"] = "(synthesized)";
|
|
329
|
+
}
|
|
330
|
+
for (const [bundlePath, buf] of processedContents.entries()) {
|
|
331
|
+
const targetFile = path.join(outputPath, bundlePath);
|
|
332
|
+
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
|
333
|
+
fs.writeFileSync(targetFile, buf);
|
|
334
|
+
}
|
|
335
|
+
const bomManifest = {
|
|
336
|
+
manifestName: manifest.name,
|
|
337
|
+
manifestVersion: manifest.version,
|
|
338
|
+
topic: options.topic,
|
|
339
|
+
topicPhase: manifest.phase,
|
|
340
|
+
topicAuthor: manifest.author,
|
|
341
|
+
topicContext: manifest.context,
|
|
342
|
+
topicScope: manifest.scope,
|
|
343
|
+
direction,
|
|
344
|
+
categoryBreakdown,
|
|
345
|
+
generatedAt: new Date().toISOString(),
|
|
346
|
+
fileCount: processedContents.size,
|
|
347
|
+
files: Array.from(processedContents.keys()),
|
|
348
|
+
sourceToBundle,
|
|
349
|
+
bundleToSource,
|
|
350
|
+
};
|
|
351
|
+
const metaInfDir = path.join(outputPath, "META-INF");
|
|
352
|
+
fs.mkdirSync(metaInfDir, { recursive: true });
|
|
353
|
+
fs.writeFileSync(path.join(metaInfDir, "bundle.manifest.json"), JSON.stringify(bomManifest, null, 2), "utf8");
|
|
354
|
+
}
|
|
355
|
+
// 6. Write updated lockfile
|
|
356
|
+
const lockfile = writeLockfile(lockfilePath, manifest, resolvedList, {
|
|
357
|
+
topic: options.topic,
|
|
358
|
+
lockfileMode: effectiveLockfileMode,
|
|
359
|
+
});
|
|
360
|
+
return {
|
|
361
|
+
outputPath,
|
|
362
|
+
filesCount: processedContents.size,
|
|
363
|
+
totalSizeBytes: totalBytes,
|
|
364
|
+
aggregateHash: lockfile.aggregateHash,
|
|
365
|
+
diagnostics: allDiagnostics,
|
|
366
|
+
skipped: false,
|
|
367
|
+
dryRun: false,
|
|
368
|
+
topic: options.topic,
|
|
369
|
+
phase: manifest.phase,
|
|
370
|
+
author: manifest.author,
|
|
371
|
+
eliminatedFiles: eliminatedFiles.length > 0 ? eliminatedFiles : undefined,
|
|
372
|
+
boundaryTerminals: boundaryTerminals.length > 0 ? boundaryTerminals : undefined,
|
|
373
|
+
upstreamNodes: upstreamNodes.length > 0 ? upstreamNodes : undefined,
|
|
374
|
+
direction,
|
|
375
|
+
categoryBreakdown,
|
|
376
|
+
plannedEntries,
|
|
377
|
+
rewrites,
|
|
378
|
+
predictedLockfile: lockfile,
|
|
379
|
+
freshness: {
|
|
380
|
+
isFresh: true,
|
|
381
|
+
currentAggregateHash: lockfile.aggregateHash,
|
|
382
|
+
lockAggregateHash: lockfile.aggregateHash,
|
|
383
|
+
added: [],
|
|
384
|
+
removed: [],
|
|
385
|
+
modified: [],
|
|
386
|
+
},
|
|
387
|
+
unbundledDiagnostics,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Inspects a bundle manifest, returning its dependency reachability tree, size breakdown,
|
|
392
|
+
* and provenance lineage for all files.
|
|
393
|
+
*/
|
|
394
|
+
export function inspectBundle(manifestPath, options = {}) {
|
|
395
|
+
const absManifest = path.resolve(process.cwd(), manifestPath);
|
|
396
|
+
const baseDir = path.dirname(absManifest);
|
|
397
|
+
const rawManifest = loadManifest(manifestPath);
|
|
398
|
+
const manifest = options.topic
|
|
399
|
+
? resolveTopicManifest(rawManifest, options.topic)
|
|
400
|
+
: rawManifest;
|
|
401
|
+
const { entries, warnings: resolveWarnings } = resolveBundleTargets(manifest, baseDir);
|
|
402
|
+
const allWarnings = [...resolveWarnings];
|
|
403
|
+
let treeConfig = manifest.treeShake;
|
|
404
|
+
if (!treeConfig || !treeConfig.entrypoints || treeConfig.entrypoints.length === 0) {
|
|
405
|
+
const rootDests = [];
|
|
406
|
+
for (const [dest] of entries.entries()) {
|
|
407
|
+
if (!dest.includes("/"))
|
|
408
|
+
rootDests.push(dest);
|
|
409
|
+
}
|
|
410
|
+
treeConfig = {
|
|
411
|
+
enabled: true,
|
|
412
|
+
entrypoints: rootDests.length > 0 ? rootDests : Array.from(entries.keys()).slice(0, 5),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
const treeShakeResult = pruneUnreachableEntries(entries, treeConfig, baseDir);
|
|
416
|
+
allWarnings.push(...treeShakeResult.warnings);
|
|
417
|
+
const treeNodes = buildDependencyTree(entries, treeConfig, baseDir);
|
|
418
|
+
const asciiTree = renderAsciiTree(treeNodes);
|
|
419
|
+
let totalSizeBytes = 0;
|
|
420
|
+
for (const e of treeShakeResult.reachableEntries.values()) {
|
|
421
|
+
totalSizeBytes += e.size;
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
manifest,
|
|
425
|
+
tree: treeNodes,
|
|
426
|
+
asciiTree,
|
|
427
|
+
reachableCount: treeShakeResult.reachableEntries.size,
|
|
428
|
+
eliminatedCount: treeShakeResult.eliminatedEntries.length,
|
|
429
|
+
totalSizeBytes,
|
|
430
|
+
warnings: allWarnings,
|
|
431
|
+
lineage: treeShakeResult.lineage,
|
|
432
|
+
boundaryTerminals: treeShakeResult.boundaryTerminals,
|
|
433
|
+
boundaryEdges: treeShakeResult.boundaryEdges,
|
|
434
|
+
upstreamNodes: treeShakeResult.upstreamNodes,
|
|
435
|
+
nodeCategories: treeShakeResult.nodeCategories,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Verifies integrity, dead links, and drift for a manifest without packaging.
|
|
440
|
+
*/
|
|
441
|
+
export function verifyBundle(manifestPath, options) {
|
|
442
|
+
const absManifest = path.resolve(process.cwd(), manifestPath);
|
|
443
|
+
const baseDir = path.dirname(absManifest);
|
|
444
|
+
const rawManifest = loadManifest(manifestPath);
|
|
445
|
+
const manifest = options?.topic
|
|
446
|
+
? resolveTopicManifest(rawManifest, options.topic)
|
|
447
|
+
: rawManifest;
|
|
448
|
+
const effectiveLockfileMode = rawManifest.lockfileMode;
|
|
449
|
+
const lockfilePath = getLockfilePath(absManifest, options?.topic, effectiveLockfileMode);
|
|
450
|
+
const freshness = checkFreshness(manifest, baseDir, lockfilePath, {
|
|
451
|
+
topic: options?.topic,
|
|
452
|
+
diff: options?.checkAnchors,
|
|
453
|
+
lockfileMode: effectiveLockfileMode,
|
|
454
|
+
});
|
|
455
|
+
const { entries, warnings } = resolveBundleTargets(manifest, baseDir);
|
|
456
|
+
let activeEntries = entries;
|
|
457
|
+
let upstreamNodes = [];
|
|
458
|
+
let nodeCategories = new Map();
|
|
459
|
+
if (manifest.treeShake?.enabled) {
|
|
460
|
+
const treeShakeResult = pruneUnreachableEntries(entries, manifest.treeShake, baseDir);
|
|
461
|
+
activeEntries = treeShakeResult.reachableEntries;
|
|
462
|
+
upstreamNodes = treeShakeResult.upstreamNodes ?? [];
|
|
463
|
+
nodeCategories = treeShakeResult.nodeCategories ?? new Map();
|
|
464
|
+
warnings.push(...treeShakeResult.warnings);
|
|
465
|
+
}
|
|
466
|
+
const sourceAbsToBundleDest = new Map();
|
|
467
|
+
for (const entry of activeEntries.values()) {
|
|
468
|
+
sourceAbsToBundleDest.set(path.resolve(entry.sourceAbsolutePath), entry.bundleDestPath);
|
|
469
|
+
}
|
|
470
|
+
const processedContents = new Map();
|
|
471
|
+
const resolvedList = Array.from(activeEntries.values());
|
|
472
|
+
for (const entry of resolvedList) {
|
|
473
|
+
let rawBuffer = fs.readFileSync(entry.sourceAbsolutePath);
|
|
474
|
+
if ((entry.bundleDestPath.endsWith(".md") || entry.bundleDestPath.endsWith(".mdx"))) {
|
|
475
|
+
let rawText = rawBuffer.toString("utf8");
|
|
476
|
+
const isContextDoc = entry.bundleDestPath.startsWith("context/") ||
|
|
477
|
+
entry.bundleDestPath.startsWith("_context/");
|
|
478
|
+
if (!isContextDoc) {
|
|
479
|
+
const category = nodeCategories.get(entry.bundleDestPath) ??
|
|
480
|
+
parseDocumentCategory(rawText, entry.sourceAbsolutePath);
|
|
481
|
+
nodeCategories.set(entry.bundleDestPath, category);
|
|
482
|
+
const fidelityMap = manifest.scope?.categoryFidelity ?? manifest.treeShake?.categoryFidelity;
|
|
483
|
+
const isUpstream = upstreamNodes.includes(entry.bundleDestPath);
|
|
484
|
+
const fidelity = (isUpstream && fidelityMap?.["upstream"]) ||
|
|
485
|
+
fidelityMap?.[category] ||
|
|
486
|
+
fidelityMap?.["default"] ||
|
|
487
|
+
"full";
|
|
488
|
+
if (fidelity !== "full" || (manifest.scope?.sections && manifest.scope.sections.length > 0)) {
|
|
489
|
+
const fidelityResult = extractDocumentFidelity(rawText, category, fidelity, manifest.scope?.sections);
|
|
490
|
+
if (fidelityResult.isModified) {
|
|
491
|
+
rawText = fidelityResult.content;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (manifest.linkResolution?.enabled !== false) {
|
|
496
|
+
const { rewritten } = rewriteMarkdownContent(rawText, entry.sourceAbsolutePath, entry.bundleDestPath, sourceAbsToBundleDest, baseDir, manifest.linkResolution);
|
|
497
|
+
rawText = rewritten;
|
|
498
|
+
}
|
|
499
|
+
rawBuffer = Buffer.from(rawText, "utf8");
|
|
500
|
+
}
|
|
501
|
+
else if (entry.bundleDestPath.endsWith(".tsp") &&
|
|
502
|
+
manifest.linkResolution?.enabled !== false) {
|
|
503
|
+
const rawText = rawBuffer.toString("utf8");
|
|
504
|
+
const { rewritten } = rewriteTypeSpecContent(rawText, entry.sourceAbsolutePath, entry.bundleDestPath, sourceAbsToBundleDest, baseDir, manifest.linkResolution);
|
|
505
|
+
rawBuffer = Buffer.from(rewritten, "utf8");
|
|
506
|
+
}
|
|
507
|
+
processedContents.set(entry.bundleDestPath, rawBuffer);
|
|
508
|
+
}
|
|
509
|
+
if (manifest.context && manifest.context.generateContextDoc !== false) {
|
|
510
|
+
const topicName = options?.topic ?? manifest.name;
|
|
511
|
+
const contextContent = generateTopicContextMarkdown(topicName, {
|
|
512
|
+
description: manifest.description,
|
|
513
|
+
targets: manifest.targets,
|
|
514
|
+
scope: manifest.scope,
|
|
515
|
+
context: manifest.context,
|
|
516
|
+
treeShake: manifest.treeShake,
|
|
517
|
+
}, resolvedList, []);
|
|
518
|
+
processedContents.set("CONTEXT.md", Buffer.from(contextContent, "utf8"));
|
|
519
|
+
}
|
|
520
|
+
const deadLinkSeverity = manifest.linkResolution?.unbundledPolicy === "warn"
|
|
521
|
+
? "warning"
|
|
522
|
+
: manifest.linkResolution?.unbundledPolicy === "error"
|
|
523
|
+
? "error"
|
|
524
|
+
: "warning";
|
|
525
|
+
const deadLinks = verifyBundleLinkIntegrity(processedContents, {
|
|
526
|
+
severity: deadLinkSeverity,
|
|
527
|
+
unbundledPolicy: manifest.linkResolution?.unbundledPolicy,
|
|
528
|
+
checkAnchors: options?.checkAnchors,
|
|
529
|
+
});
|
|
530
|
+
return { freshness, deadLinks, warnings };
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Extracts an archive file into target destination directory.
|
|
534
|
+
*/
|
|
535
|
+
export function unpackBundle(archivePath, destDir) {
|
|
536
|
+
const absArchive = path.resolve(process.cwd(), archivePath);
|
|
537
|
+
const absDest = path.resolve(process.cwd(), destDir);
|
|
538
|
+
const zipBuffer = fs.readFileSync(absArchive);
|
|
539
|
+
return extractZipArchive(zipBuffer, absDest);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Resolves a source path, relative reference, or TypeSpec citation to its
|
|
543
|
+
* exact coordinate within a packaged bundle archive or extracted directory.
|
|
544
|
+
*/
|
|
545
|
+
export function resolveBundlePath(archiveInput, queryReference, options) {
|
|
546
|
+
let fileList = [];
|
|
547
|
+
let sourceToBundle = {};
|
|
548
|
+
let bundleToSource = {};
|
|
549
|
+
if (Buffer.isBuffer(archiveInput)) {
|
|
550
|
+
const entries = readZipArchive(archiveInput);
|
|
551
|
+
fileList = Array.from(entries.keys()).map((k) => k.replace(/\\/g, "/"));
|
|
552
|
+
const manifestBuf = entries.get("META-INF/bundle.manifest.json");
|
|
553
|
+
if (manifestBuf) {
|
|
554
|
+
try {
|
|
555
|
+
const parsed = JSON.parse(manifestBuf.toString("utf8"));
|
|
556
|
+
if (parsed.sourceToBundle && typeof parsed.sourceToBundle === "object") {
|
|
557
|
+
sourceToBundle = parsed.sourceToBundle;
|
|
558
|
+
}
|
|
559
|
+
if (parsed.bundleToSource && typeof parsed.bundleToSource === "object") {
|
|
560
|
+
bundleToSource = parsed.bundleToSource;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
// ignore malformed manifest
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
else if (typeof archiveInput === "string") {
|
|
569
|
+
const absPath = path.resolve(process.cwd(), archiveInput);
|
|
570
|
+
if (!fs.existsSync(absPath)) {
|
|
571
|
+
throw new Error(`Archive input path does not exist: ${archiveInput}`);
|
|
572
|
+
}
|
|
573
|
+
const stat = fs.statSync(absPath);
|
|
574
|
+
if (stat.isFile()) {
|
|
575
|
+
const zipBuf = fs.readFileSync(absPath);
|
|
576
|
+
const entries = readZipArchive(zipBuf);
|
|
577
|
+
fileList = Array.from(entries.keys()).map((k) => k.replace(/\\/g, "/"));
|
|
578
|
+
const manifestBuf = entries.get("META-INF/bundle.manifest.json");
|
|
579
|
+
if (manifestBuf) {
|
|
580
|
+
try {
|
|
581
|
+
const parsed = JSON.parse(manifestBuf.toString("utf8"));
|
|
582
|
+
if (parsed.sourceToBundle && typeof parsed.sourceToBundle === "object") {
|
|
583
|
+
sourceToBundle = parsed.sourceToBundle;
|
|
584
|
+
}
|
|
585
|
+
if (parsed.bundleToSource && typeof parsed.bundleToSource === "object") {
|
|
586
|
+
bundleToSource = parsed.bundleToSource;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// ignore
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
else if (stat.isDirectory()) {
|
|
595
|
+
const bomPath = path.join(absPath, "META-INF", "bundle.manifest.json");
|
|
596
|
+
if (fs.existsSync(bomPath)) {
|
|
597
|
+
try {
|
|
598
|
+
const parsed = JSON.parse(fs.readFileSync(bomPath, "utf8"));
|
|
599
|
+
if (parsed.sourceToBundle && typeof parsed.sourceToBundle === "object") {
|
|
600
|
+
sourceToBundle = parsed.sourceToBundle;
|
|
601
|
+
}
|
|
602
|
+
if (parsed.bundleToSource && typeof parsed.bundleToSource === "object") {
|
|
603
|
+
bundleToSource = parsed.bundleToSource;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
// ignore
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const walk = (dir, rel = "") => {
|
|
611
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
612
|
+
const itemRel = rel ? `${rel}/${item.name}` : item.name;
|
|
613
|
+
if (item.isDirectory()) {
|
|
614
|
+
walk(path.join(dir, item.name), itemRel);
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
fileList.push(itemRel.replace(/\\/g, "/"));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
walk(absPath);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
throw new Error("Invalid archive input: expected Buffer or string path");
|
|
626
|
+
}
|
|
627
|
+
const existingFiles = new Set(fileList);
|
|
628
|
+
// 1. Split query reference into base path, anchor, and query
|
|
629
|
+
const normalizedQuery = queryReference.replace(/\\/g, "/");
|
|
630
|
+
const hashIdx = normalizedQuery.indexOf("#");
|
|
631
|
+
const queryIdx = normalizedQuery.indexOf("?");
|
|
632
|
+
let splitIdx = -1;
|
|
633
|
+
if (hashIdx !== -1 && queryIdx !== -1) {
|
|
634
|
+
splitIdx = Math.min(hashIdx, queryIdx);
|
|
635
|
+
}
|
|
636
|
+
else if (hashIdx !== -1) {
|
|
637
|
+
splitIdx = hashIdx;
|
|
638
|
+
}
|
|
639
|
+
else if (queryIdx !== -1) {
|
|
640
|
+
splitIdx = queryIdx;
|
|
641
|
+
}
|
|
642
|
+
const basePath = splitIdx === -1 ? normalizedQuery : normalizedQuery.slice(0, splitIdx);
|
|
643
|
+
const suffix = splitIdx === -1 ? "" : normalizedQuery.slice(splitIdx);
|
|
644
|
+
const anchor = hashIdx !== -1 ? normalizedQuery.slice(hashIdx) : undefined;
|
|
645
|
+
const cleanBase = basePath.replace(/^\/+/, "").replace(/^\.\//, "");
|
|
646
|
+
// 2. Resolve to bundle path
|
|
647
|
+
let resolvedBundlePath;
|
|
648
|
+
// Case A: Query is already a valid bundle path
|
|
649
|
+
if (existingFiles.has(cleanBase)) {
|
|
650
|
+
resolvedBundlePath = cleanBase;
|
|
651
|
+
}
|
|
652
|
+
// Case B: Direct match in sourceToBundle mapping
|
|
653
|
+
if (!resolvedBundlePath && sourceToBundle[cleanBase]) {
|
|
654
|
+
resolvedBundlePath = sourceToBundle[cleanBase];
|
|
655
|
+
}
|
|
656
|
+
// Case C: Normalized path in sourceToBundle or existingFiles
|
|
657
|
+
if (!resolvedBundlePath) {
|
|
658
|
+
const normalizedClean = path.posix.normalize(cleanBase);
|
|
659
|
+
if (sourceToBundle[normalizedClean]) {
|
|
660
|
+
resolvedBundlePath = sourceToBundle[normalizedClean];
|
|
661
|
+
}
|
|
662
|
+
else if (existingFiles.has(normalizedClean)) {
|
|
663
|
+
resolvedBundlePath = normalizedClean;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// Case D: If baseDir is provided, resolve relative to baseDir inside bundle
|
|
667
|
+
if (!resolvedBundlePath && options?.baseDir) {
|
|
668
|
+
const fromBase = path.posix.normalize(path.posix.join(options.baseDir, cleanBase));
|
|
669
|
+
if (existingFiles.has(fromBase)) {
|
|
670
|
+
resolvedBundlePath = fromBase;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Case E: Suffix / partial path matching in sourceToBundle (e.g. "req.md" or "docs/req.md" matching "templates/.../docs/req.md")
|
|
674
|
+
if (!resolvedBundlePath) {
|
|
675
|
+
for (const [srcKey, bundleVal] of Object.entries(sourceToBundle)) {
|
|
676
|
+
if (srcKey === cleanBase || srcKey.endsWith(`/${cleanBase}`)) {
|
|
677
|
+
resolvedBundlePath = bundleVal;
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// Case F: Basename match in existing files if unique
|
|
683
|
+
if (!resolvedBundlePath) {
|
|
684
|
+
const baseName = path.posix.basename(cleanBase).toLowerCase();
|
|
685
|
+
const matches = Array.from(existingFiles).filter((f) => path.posix.basename(f).toLowerCase() === baseName);
|
|
686
|
+
if (matches.length === 1) {
|
|
687
|
+
resolvedBundlePath = matches[0];
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const existsInBundle = resolvedBundlePath !== undefined && existingFiles.has(resolvedBundlePath);
|
|
691
|
+
const finalBundlePath = resolvedBundlePath ?? cleanBase;
|
|
692
|
+
const fullReference = `${finalBundlePath}${suffix}`;
|
|
693
|
+
const suggestedTarget = !existsInBundle
|
|
694
|
+
? (findClosestFile(cleanBase, existingFiles) ?? undefined)
|
|
695
|
+
: undefined;
|
|
696
|
+
return {
|
|
697
|
+
bundlePath: finalBundlePath,
|
|
698
|
+
anchor,
|
|
699
|
+
fullReference,
|
|
700
|
+
existsInBundle,
|
|
701
|
+
originalQuery: queryReference,
|
|
702
|
+
suggestedTarget,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
//# sourceMappingURL=index.js.map
|