@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,27 @@
1
+ import type { BundleManifest, DeduplicationStrategy, ResolvedFileEntry } from "./types.js";
2
+ /**
3
+ * Expands braces in glob patterns, e.g. "docs/{a,b}.md" -> ["docs/a.md", "docs/b.md"]
4
+ */
5
+ export declare function expandBraces(pattern: string): string[];
6
+ /**
7
+ * Converts a glob pattern to a regular expression.
8
+ */
9
+ export declare function globToRegex(glob: string): RegExp;
10
+ /**
11
+ * Matches a relative path against an array of glob exclusion patterns.
12
+ */
13
+ export declare function matchesAnyPattern(relativePath: string, patterns: string[]): boolean;
14
+ /**
15
+ * Resolves all target mappings against project root, enforcing deduplication and exclusion filters.
16
+ */
17
+ export declare function resolveBundleTargets(manifest: BundleManifest, baseDir: string): {
18
+ entries: Map<string, ResolvedFileEntry>;
19
+ contentDuplicates: Map<string, string[]>;
20
+ warnings: string[];
21
+ };
22
+ /**
23
+ * Recursively crawls relative TypeSpec imports (`import "./file.tsp"`)
24
+ * for resolved .tsp files, discovering local spec dependencies automatically.
25
+ */
26
+ export declare function crawlTypeSpecImports(entries: Map<string, ResolvedFileEntry>, baseDir: string, contentDuplicates: Map<string, string[]>, warnings: string[], collisionStrategy: DeduplicationStrategy, excludes?: string[]): void;
27
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1,276 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ /**
5
+ * Expands braces in glob patterns, e.g. "docs/{a,b}.md" -> ["docs/a.md", "docs/b.md"]
6
+ */
7
+ export function expandBraces(pattern) {
8
+ const match = pattern.match(/\{([^{}]+)\}/);
9
+ if (!match)
10
+ return [pattern];
11
+ const before = pattern.slice(0, match.index);
12
+ const after = pattern.slice((match.index ?? 0) + match[0].length);
13
+ const parts = match[1].split(",");
14
+ const results = [];
15
+ for (const part of parts) {
16
+ for (const expanded of expandBraces(`${before}${part}${after}`)) {
17
+ results.push(expanded);
18
+ }
19
+ }
20
+ return results;
21
+ }
22
+ /**
23
+ * Converts a glob pattern to a regular expression.
24
+ */
25
+ export function globToRegex(glob) {
26
+ const normalized = glob.replace(/\\/g, "/");
27
+ let regexStr = "";
28
+ let i = 0;
29
+ while (i < normalized.length) {
30
+ const c = normalized[i];
31
+ if (c === "*" && normalized[i + 1] === "*") {
32
+ if (normalized[i + 2] === "/") {
33
+ regexStr += "(?:.*/)?";
34
+ i += 3;
35
+ }
36
+ else {
37
+ regexStr += ".*";
38
+ i += 2;
39
+ }
40
+ }
41
+ else if (c === "*") {
42
+ regexStr += "[^/]*";
43
+ i += 1;
44
+ }
45
+ else if (c === "?") {
46
+ regexStr += "[^/]";
47
+ i += 1;
48
+ }
49
+ else if (["[", "]", "(", ")", "+", ".", "^", "$", "|"].includes(c)) {
50
+ regexStr += "\\" + c;
51
+ i += 1;
52
+ }
53
+ else {
54
+ regexStr += c;
55
+ i += 1;
56
+ }
57
+ }
58
+ return new RegExp(`^${regexStr}$`);
59
+ }
60
+ /**
61
+ * Matches a relative path against an array of glob exclusion patterns.
62
+ */
63
+ export function matchesAnyPattern(relativePath, patterns) {
64
+ const posixPath = relativePath.replace(/\\/g, "/");
65
+ for (const pattern of patterns) {
66
+ for (const expanded of expandBraces(pattern)) {
67
+ const regex = globToRegex(expanded);
68
+ if (regex.test(posixPath))
69
+ return true;
70
+ // If pattern didn't start with **/ or /, also test if it matches the basename
71
+ if (!expanded.includes("/") && regex.test(path.posix.basename(posixPath))) {
72
+ return true;
73
+ }
74
+ }
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * Recursively scans directory returning POSIX-relative file paths.
80
+ */
81
+ function scanDirectory(baseDir, currentSubDir = "") {
82
+ const fullDir = currentSubDir ? path.join(baseDir, currentSubDir) : baseDir;
83
+ if (!fs.existsSync(fullDir))
84
+ return [];
85
+ const files = [];
86
+ const entries = fs.readdirSync(fullDir, { withFileTypes: true });
87
+ for (const entry of entries) {
88
+ const subRel = currentSubDir ? `${currentSubDir}/${entry.name}` : entry.name;
89
+ const posixRel = subRel.replace(/\\/g, "/");
90
+ if (entry.isDirectory()) {
91
+ if (entry.name === ".git" || entry.name === "node_modules")
92
+ continue;
93
+ files.push(...scanDirectory(baseDir, posixRel));
94
+ }
95
+ else if (entry.isFile()) {
96
+ files.push(posixRel);
97
+ }
98
+ }
99
+ return files;
100
+ }
101
+ /**
102
+ * Determines the static base directory of a glob pattern before any wildcards.
103
+ */
104
+ function getStaticBaseDirectory(pattern) {
105
+ const normalized = pattern.replace(/\\/g, "/");
106
+ const firstWildcard = normalized.search(/[*?{[]/);
107
+ if (firstWildcard === -1) {
108
+ return path.posix.dirname(normalized);
109
+ }
110
+ const prefix = normalized.slice(0, firstWildcard);
111
+ const lastSlash = prefix.lastIndexOf("/");
112
+ return lastSlash === -1 ? "." : prefix.slice(0, lastSlash);
113
+ }
114
+ /**
115
+ * Resolves all target mappings against project root, enforcing deduplication and exclusion filters.
116
+ */
117
+ export function resolveBundleTargets(manifest, baseDir) {
118
+ const entries = new Map();
119
+ const contentDuplicates = new Map(); // sha256 -> destPaths
120
+ const warnings = [];
121
+ const collisionStrategy = manifest.deduplicate?.byPath ?? "warn-overwrite";
122
+ const globalExcludes = manifest.exclude ?? [];
123
+ for (const target of manifest.targets) {
124
+ const targetExcludes = [...globalExcludes, ...(target.exclude ?? [])];
125
+ const expandedSources = expandBraces(target.source);
126
+ for (const sourcePattern of expandedSources) {
127
+ const posixSource = sourcePattern.replace(/\\/g, "/");
128
+ const isDirectFile = !/[*?{[]/.test(posixSource);
129
+ if (isDirectFile) {
130
+ const absPath = path.resolve(baseDir, posixSource);
131
+ if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) {
132
+ warnings.push(`Target source file not found: ${posixSource}`);
133
+ continue;
134
+ }
135
+ const relPath = path.relative(baseDir, absPath).replace(/\\/g, "/");
136
+ if (matchesAnyPattern(relPath, targetExcludes))
137
+ continue;
138
+ let bundleDest;
139
+ if (target.dest.endsWith("/")) {
140
+ bundleDest = path.posix.join(target.dest, path.posix.basename(relPath));
141
+ }
142
+ else {
143
+ bundleDest = target.dest.replace(/\\/g, "/");
144
+ }
145
+ bundleDest = path.posix.normalize(bundleDest).replace(/^\/+/, "");
146
+ addFileEntry(absPath, relPath, bundleDest, entries, contentDuplicates, warnings, collisionStrategy);
147
+ }
148
+ else {
149
+ // Glob pattern
150
+ const staticBase = getStaticBaseDirectory(posixSource);
151
+ const absScanDir = path.resolve(baseDir, staticBase);
152
+ const allFiles = scanDirectory(absScanDir);
153
+ const patternRegex = globToRegex(posixSource);
154
+ for (const fileRelToStatic of allFiles) {
155
+ const fileRelToProject = staticBase === "."
156
+ ? fileRelToStatic
157
+ : path.posix.join(staticBase, fileRelToStatic);
158
+ if (!patternRegex.test(fileRelToProject))
159
+ continue;
160
+ if (matchesAnyPattern(fileRelToProject, targetExcludes))
161
+ continue;
162
+ const absPath = path.resolve(baseDir, fileRelToProject);
163
+ let bundleDest;
164
+ if (target.dest.endsWith("/")) {
165
+ // Keep relative path structure starting from staticBase
166
+ bundleDest = path.posix.join(target.dest, fileRelToStatic);
167
+ }
168
+ else {
169
+ bundleDest = path.posix.join(target.dest, fileRelToStatic);
170
+ }
171
+ bundleDest = path.posix.normalize(bundleDest).replace(/^\/+/, "");
172
+ addFileEntry(absPath, fileRelToProject, bundleDest, entries, contentDuplicates, warnings, collisionStrategy);
173
+ }
174
+ }
175
+ }
176
+ }
177
+ crawlTypeSpecImports(entries, baseDir, contentDuplicates, warnings, collisionStrategy, globalExcludes);
178
+ return { entries, contentDuplicates, warnings };
179
+ }
180
+ /**
181
+ * Recursively crawls relative TypeSpec imports (`import "./file.tsp"`)
182
+ * for resolved .tsp files, discovering local spec dependencies automatically.
183
+ */
184
+ export function crawlTypeSpecImports(entries, baseDir, contentDuplicates, warnings, collisionStrategy, excludes = []) {
185
+ const queue = [];
186
+ for (const entry of entries.values()) {
187
+ if (entry.bundleDestPath.endsWith(".tsp") ||
188
+ entry.sourceAbsolutePath.endsWith(".tsp")) {
189
+ queue.push(entry);
190
+ }
191
+ }
192
+ const visited = new Set();
193
+ for (const item of queue) {
194
+ visited.add(path.resolve(item.sourceAbsolutePath));
195
+ }
196
+ while (queue.length > 0) {
197
+ const current = queue.shift();
198
+ if (!fs.existsSync(current.sourceAbsolutePath))
199
+ continue;
200
+ let content;
201
+ try {
202
+ content = fs.readFileSync(current.sourceAbsolutePath, "utf8");
203
+ }
204
+ catch {
205
+ continue;
206
+ }
207
+ const importRegex = /import\s+["'](\.[^"']+)["']/g;
208
+ let match;
209
+ while ((match = importRegex.exec(content)) !== null) {
210
+ const rawSpecifier = match[1];
211
+ const sourceDir = path.dirname(current.sourceAbsolutePath);
212
+ const candidatePaths = [
213
+ path.resolve(sourceDir, rawSpecifier),
214
+ path.resolve(sourceDir, `${rawSpecifier}.tsp`),
215
+ path.resolve(sourceDir, rawSpecifier, "main.tsp"),
216
+ path.resolve(sourceDir, rawSpecifier, "index.tsp"),
217
+ ];
218
+ let targetAbsPath = null;
219
+ for (const cand of candidatePaths) {
220
+ if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
221
+ targetAbsPath = cand;
222
+ break;
223
+ }
224
+ }
225
+ if (!targetAbsPath)
226
+ continue;
227
+ if (visited.has(targetAbsPath))
228
+ continue;
229
+ visited.add(targetAbsPath);
230
+ const relToProject = path.relative(baseDir, targetAbsPath).replace(/\\/g, "/");
231
+ if (matchesAnyPattern(relToProject, excludes))
232
+ continue;
233
+ const relBetween = path
234
+ .relative(path.dirname(current.sourceAbsolutePath), targetAbsPath)
235
+ .replace(/\\/g, "/");
236
+ let bundleDest = path.posix.normalize(path.posix.join(path.posix.dirname(current.bundleDestPath), relBetween));
237
+ bundleDest = bundleDest.replace(/^\/+/, "");
238
+ if (bundleDest.startsWith("../") || bundleDest === "..") {
239
+ bundleDest = relToProject;
240
+ }
241
+ addFileEntry(targetAbsPath, relToProject, bundleDest, entries, contentDuplicates, warnings, collisionStrategy);
242
+ const newEntry = entries.get(bundleDest);
243
+ if (newEntry) {
244
+ queue.push(newEntry);
245
+ }
246
+ }
247
+ }
248
+ }
249
+ function addFileEntry(absPath, relPath, bundleDest, entries, contentDuplicates, warnings, collisionStrategy) {
250
+ const stat = fs.statSync(absPath);
251
+ const buffer = fs.readFileSync(absPath);
252
+ const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
253
+ if (entries.has(bundleDest)) {
254
+ const existing = entries.get(bundleDest);
255
+ if (collisionStrategy === "error") {
256
+ throw new Error(`Destination path collision for '${bundleDest}': '${relPath}' conflicts with '${existing.sourceRelativePath}'`);
257
+ }
258
+ else if (collisionStrategy === "warn-overwrite") {
259
+ warnings.push(`Destination collision for '${bundleDest}': overwriting '${existing.sourceRelativePath}' with '${relPath}'`);
260
+ }
261
+ // "overwrite" or "warn-overwrite" replaces existing
262
+ }
263
+ const entry = {
264
+ sourceAbsolutePath: absPath,
265
+ sourceRelativePath: relPath,
266
+ bundleDestPath: bundleDest,
267
+ sha256,
268
+ size: stat.size,
269
+ mtime: stat.mtimeMs,
270
+ };
271
+ entries.set(bundleDest, entry);
272
+ const existingDups = contentDuplicates.get(sha256) ?? [];
273
+ existingDups.push(bundleDest);
274
+ contentDuplicates.set(sha256, existingDups);
275
+ }
276
+ //# sourceMappingURL=resolver.js.map