Skill Tracer
Skills › Archify › archify › output-path.mjs

output-path.mjs

440 lines · archify/renderers/shared/output-path.mjs at a406594

1import path from 'node:path';
2import {
3 containedBy,
4 isValidWindowsSmbShareName,
5 isWindowsIpcShare,
6 resolvePhysicalLocation,
7 sameLocation,
8} from './path-semantics.mjs';
9import { PortablePathError, validatePortablePath } from './portable-path.mjs';
11export function canonicalFuturePath(targetPath) {
12 const resolution = resolvePhysicalLocation(targetPath);
13 if (resolution.status === 'resolved') {
14 if (resolution.location.kind === 'existing') return resolution.location.path;
15 return path.join(
16 resolution.location.ancestorPath,
17 ...resolution.location.unresolved,
18 );
19 }
20 const output = path.resolve(targetPath);
21 if (resolution.reason.code === 'symlink-cycle') {
22 throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
23 code: 'output/symlink-cycle',
24 message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
25 subject: { output },
26 evidence: { relation: resolution.reason },
27 supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
28 });
29 }
30 throw new OutputPathError('Output path could not be resolved safely.', {
31 code: 'output/path-resolution-indeterminate',
32 message: 'Output path could not be resolved safely for the requested filesystem location.',
33 subject: { output },
34 evidence: { relation: resolution.reason },
35 supportedFixes: ['use an ordinary local filesystem path that can be resolved safely, then retry'],
36 });
39export function pathsAlias(leftPath, rightPath) {
40 const relation = sameLocation(leftPath, rightPath);
41 if (relation.status === 'match') return true;
42 if (relation.status === 'different') return false;
43 if (relation.reason.code === 'ancestor-not-directory') return false;
44 if (relation.reason.code === 'symlink-cycle') {
45 const output = path.resolve(relation.reason.side === 'right' ? rightPath : leftPath);
46 throw new OutputPathError(`Output path contains a symbolic-link cycle: "${output}".`, {
47 code: 'output/symlink-cycle',
48 message: 'Output path could not be resolved because it contains a symbolic-link cycle.',
49 subject: { output },
50 evidence: { relation: relation.reason },
51 supportedFixes: ['remove the symbolic-link cycle or choose an output path outside it'],
52 });
53 }
54 throw new OutputPathError('Path identity could not be determined safely.', {
55 code: 'output/path-identity-indeterminate',
56 message: 'Path identity could not be determined safely for the requested filesystem location.',
57 subject: { left: path.resolve(leftPath), right: path.resolve(rightPath) },
58 evidence: { relation: relation.reason },
59 supportedFixes: ['use ordinary local filesystem paths whose identity can be verified, then retry'],
60 });
63function pathIsInside(directoryPath, targetPath) {
64 const relation = containedBy(directoryPath, targetPath);
65 if (relation.status === 'match') return true;
66 if (relation.status === 'different') return false;
67 throw new OutputPathError('Output containment could not be determined safely.', {
68 code: 'output/containment-indeterminate',
69 message: 'Output containment could not be determined safely for the requested filesystem location.',
70 subject: { output: path.resolve(targetPath), cwd: path.resolve(directoryPath) },
71 evidence: { relation: relation.reason },
72 supportedFixes: ['use an output beneath an ordinary local directory whose identity can be verified'],
73 });
76function authoredOutputDiagnostic(error, rawOutput) {
77 const absolute = error?.reason === 'absolute';
78 const code = absolute ? 'output/meta-absolute' : 'output/meta-path-syntax';
79 const message = absolute
80 ? 'meta.output must be a relative path resolved from the current working directory.'
81 : 'meta.output must be a portable POSIX-relative path.';
82 return {
83 code,
84 message,
85 subject: { output: rawOutput, path: '/meta/output' },
86 evidence: {
87 reason: error?.reason || 'invalid',
88 ...(error?.segmentIndex !== undefined ? { segmentIndex: error.segmentIndex } : {}),
89 ...(error?.segment !== undefined ? { segment: error.segment } : {}),
90 ...(error?.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
91 ...(error?.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
92 ...(error?.limit !== undefined ? { limit: error.limit } : {}),
93 },
94 supportedFixes: ['set meta.output to a portable relative .html path such as reports/diagram.html'],
95 };
98function nativeOutputDiagnostic(rawOutput, reason, details = {}) {
99 return {
100 code: 'output/native-path-syntax',
101 message: 'The output path is not a valid native filesystem path on this host.',
102 subject: { output: rawOutput },
103 evidence: { reason, ...details },
104 supportedFixes: ['choose an ordinary filesystem path without device names, alternate data streams, trailing dots or spaces, or overlong components'],
105 };
108function throwNativeOutputDiagnostic(rawOutput, reason, details = {}) {
109 const diagnostic = nativeOutputDiagnostic(rawOutput, reason, details);
110 throw new OutputPathError(diagnostic.message, diagnostic);
113function windowsExtendedTailComponents(rawOutput, tail) {
114 const authoredComponents = tail.split('\\');
115 // Preserve one trailing separator for directory arguments, but never repair
116 // an empty component inside the raw extended namespace.
117 if (authoredComponents.slice(0, -1).some((component) => component.length === 0)) {
118 throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
119 }
120 return authoredComponents.filter(Boolean);
123function rejectWindowsIpcShare(rawOutput, share) {
124 if (isWindowsIpcShare(share)) {
125 throwNativeOutputDiagnostic(rawOutput, 'windows-ipc-namespace', { share });
126 }
129function validateWindowsNativeComponent(
130 rawOutput,
131 component,
132 componentIndex,
133 { allowReservedName = false } = {},
134) {
135 try {
136 // Prefix the component so a colon is classified as an ADS separator,
137 // rather than allowing the generic URI detector to claim it first.
138 validatePortablePath(`native/${component}`, { profile: 'output' });
139 } catch (error) {
140 if (!(error instanceof PortablePathError)) throw error;
141 if (allowReservedName && error.reason === 'windows-reserved-name') return;
142 // Native Windows arguments may intentionally name an existing 8.3 alias.
143 // Portable authored/archive paths reject that ambiguous spelling, while
144 // native resolution lets the filesystem prove the existing target.
145 if (error.reason === 'windows-short-name') {
146 if (component.length <= 255) return;
147 throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
148 component,
149 componentIndex,
150 utf16CodeUnits: component.length,
151 limit: 255,
152 });
153 }
154 // Native Windows filesystems bound components in UTF-16 code units. The
155 // stricter UTF-8 bound belongs to portable authored/archive names only.
156 if (error.reason === 'component-too-long' && error.utf16CodeUnits <= 255) return;
157 throwNativeOutputDiagnostic(rawOutput, error.reason, {
158 component,
159 componentIndex,
160 ...(error.character !== undefined ? { character: error.character } : {}),
161 ...(error.utf8Bytes !== undefined ? { utf8Bytes: error.utf8Bytes } : {}),
162 ...(error.utf16CodeUnits !== undefined ? { utf16CodeUnits: error.utf16CodeUnits } : {}),
163 ...(error.limit !== undefined ? { limit: error.limit } : {}),
164 });
165 }
168function validateWindowsUncRootComponents(rawOutput, server, share) {
169 rejectWindowsIpcShare(rawOutput, share);
170 if (!isValidWindowsSmbShareName(share)) {
171 throwNativeOutputDiagnostic(rawOutput, 'windows-unc-share-name', {
172 share,
173 utf16CodeUnits: share.length,
174 limit: 80,
175 });
176 }
177 // UNC servers and shares are root components, not DOS file names. Keep the
178 // server's ordinary native syntax checks while allowing names such as CON.
179 validateWindowsNativeComponent(rawOutput, server, 0, { allowReservedName: true });
182function windowsExtendedPathComponents(rawOutput) {
183 // The extended-length namespace deliberately bypasses Win32 normalization.
184 // Inspect its original spelling so a dot segment cannot retarget a UNC share.
185 if (rawOutput.includes('/')) {
186 throwNativeOutputDiagnostic(rawOutput, 'windows-extended-separator', { character: '/' });
187 }
188 if (/^\\\\\?\\(?:GLOBALROOT|Device)(?:\\|$)/iu.test(rawOutput)) {
189 throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
190 }
192 const drive = rawOutput.match(/^\\\\\?\\[A-Za-z]:\\/u);
193 if (drive) {
194 return windowsExtendedTailComponents(rawOutput, rawOutput.slice(drive[0].length));
195 }
197 const uncPrefix = rawOutput.match(/^\\\\\?\\UNC\\/iu);
198 if (uncPrefix) {
199 const authoredTail = rawOutput.slice(uncPrefix[0].length);
200 const components = authoredTail.split('\\');
201 if (components.length < 2 || components[0].length === 0 || components[1].length === 0) {
202 throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
203 }
204 validateWindowsUncRootComponents(rawOutput, components[0], components[1]);
205 return windowsExtendedTailComponents(rawOutput, components.slice(2).join('\\'));
206 }
208 throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
211function validateWindowsRawUncRoot(rawOutput) {
212 if (!/^[\\/]{2}/u.test(rawOutput) || /^[\\/]{2}[.?][\\/]/u.test(rawOutput)) return;
213 // Validate the raw server/share boundary before win32.normalize can collapse
214 // an empty share or mix the two UNC separator spellings.
215 const unc = rawOutput.match(/^([\\/])\1([^\\/]+)\1([^\\/]+)(?:[\\/]|$)/u);
216 if (!unc) {
217 throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
218 }
219 validateWindowsUncRootComponents(rawOutput, unc[2], unc[3]);
222function windowsPathComponents(rawOutput, normalized) {
223 const authoredUncPrefix = /^[\\/]{2}/u.test(rawOutput);
224 if (/^\\\\\.\\/u.test(normalized)) {
225 throwNativeOutputDiagnostic(rawOutput, 'windows-device-namespace');
226 }
227 if (/^\\\\\?\\/u.test(normalized)) {
228 throwNativeOutputDiagnostic(rawOutput, 'windows-extended-root');
229 }
230 if (normalized.startsWith('\\\\')) {
231 const unc = normalized.match(/^\\\\([^\\]+)\\([^\\]+)(?:\\|$)/u);
232 if (!unc) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
233 return normalized.slice(unc[0].length).split('\\').filter(Boolean);
234 }
235 if (authoredUncPrefix) throwNativeOutputDiagnostic(rawOutput, 'windows-unc-root');
236 if (normalized.startsWith('\\')) {
237 throwNativeOutputDiagnostic(rawOutput, 'current-drive-rooted');
238 }
239 const root = path.win32.parse(normalized).root;
240 return normalized
241 .slice(root.length)
242 .split(/[\\/]+/u)
243 // normalize() retains leading navigation for a relative path. Those dot
244 // segments are path syntax, not filename components subject to name rules.
245 .filter((component) => component && component !== '.' && component !== '..');
248/**
249 * Validate command-line/default output paths using the active host's native
250 * syntax. Unlike authored portable paths, absolute paths and native separators
251 * remain supported. File outputs reject a trailing separator before native
252 * resolution can erase it; directory callers must opt in explicitly. The
253 * component bound also protects derived sidecars from failing after an
254 * operation has already started mutating the destination.
255 */
256export function validateNativeOutputPath(
257 rawOutput,
258 { platform = process.platform, kind = 'file' } = {},
259) {
260 if (kind !== 'file' && kind !== 'directory') {
261 throw new TypeError(`Unsupported native output path kind: ${JSON.stringify(kind)}`);
262 }
263 if (typeof rawOutput !== 'string' || rawOutput.length === 0) {
264 throwNativeOutputDiagnostic(rawOutput, 'empty');
265 }
266 if (rawOutput.includes('\0')) throwNativeOutputDiagnostic(rawOutput, 'nul-character');
267 if (/[\uD800-\uDFFF]/u.test(rawOutput)) {
268 throwNativeOutputDiagnostic(rawOutput, 'unpaired-surrogate');
269 }
270 const hasTrailingSeparator = platform === 'win32'
271 ? /[\\/]$/u.test(rawOutput)
272 : rawOutput.endsWith(path.posix.sep);
273 if (kind === 'file' && hasTrailingSeparator) {
274 throwNativeOutputDiagnostic(rawOutput, 'trailing-separator', { kind });
275 }
277 let normalized;
278 let components;
279 if (platform === 'win32') {
280 if (/^[A-Za-z]:(?:$|[^\\/])/u.test(rawOutput)) {
281 throwNativeOutputDiagnostic(rawOutput, 'drive-relative');
282 }
283 const extendedPrefix = rawOutput.startsWith('\\\\?\\');
284 if (extendedPrefix) {
285 components = windowsExtendedPathComponents(rawOutput);
286 } else {
287 validateWindowsRawUncRoot(rawOutput);
288 normalized = path.win32.normalize(rawOutput);
289 components = windowsPathComponents(rawOutput, normalized);
290 }
291 for (const [componentIndex, component] of components.entries()) {
292 validateWindowsNativeComponent(rawOutput, component, componentIndex);
293 }
294 } else {
295 normalized = path.resolve(rawOutput);
296 const root = path.parse(normalized).root;
297 components = normalized.slice(root.length).split(path.sep).filter(Boolean);
298 for (const [componentIndex, component] of components.entries()) {
299 const utf8Bytes = Buffer.byteLength(component, 'utf8');
300 if (utf8Bytes > 255) {
301 throwNativeOutputDiagnostic(rawOutput, 'component-too-long', {
302 component,
303 componentIndex,
304 utf8Bytes,
305 limit: 255,
306 });
307 }
308 }
309 }
310 return rawOutput;
313/** Validate a CLI directory argument before native resolution can normalize away its raw syntax. */
314export function resolveNativeOutputDirectory(
315 rawDirectory,
316 { platform = process.platform, cwd = process.cwd() } = {},
317) {
318 validateNativeOutputPath(rawDirectory, { platform, kind: 'directory' });
319 const pathApi = platform === 'win32' ? path.win32 : path.posix;
320 return pathApi.resolve(cwd, rawDirectory);
323export function validateAuthoredOutputPath(rawOutput, { cwd = process.cwd() } = {}) {
324 try {
325 validatePortablePath(rawOutput, { profile: 'output' });
326 } catch (error) {
327 if (!(error instanceof PortablePathError)) throw error;
328 const diagnostic = authoredOutputDiagnostic(error, rawOutput);
329 throw new OutputPathError(diagnostic.message, diagnostic);
330 }
331 if (path.posix.extname(rawOutput).toLowerCase() !== '.html') {
332 throw new OutputPathError('meta.output must target an .html file.', {
333 code: 'output/meta-extension',
334 message: 'meta.output must target an .html file.',
335 subject: { output: rawOutput, path: '/meta/output' },
336 supportedFixes: ['change meta.output to a portable path ending in .html'],
337 });
338 }
339 const outputPath = path.resolve(cwd, rawOutput);
340 if (path.extname(canonicalFuturePath(outputPath)).toLowerCase() !== '.html') {
341 throw new OutputPathError('meta.output must resolve to an .html file.', {
342 code: 'output/meta-resolved-extension',
343 message: 'meta.output must resolve to an .html file after symbolic links are followed.',
344 subject: { output: rawOutput },
345 supportedFixes: ['remove the symbolic-link alias or point it to an .html target inside the current working directory'],
346 });
347 }
348 if (!pathIsInside(cwd, outputPath)) {
349 throw new OutputPathError('meta.output must stay inside the current working directory.', {
350 code: 'output/meta-outside-cwd',
351 message: 'meta.output must stay inside the current working directory after symbolic links are resolved.',
352 subject: { output: rawOutput, cwd: path.resolve(cwd) },
353 supportedFixes: ['set meta.output to a relative .html path inside the current working directory'],
354 });
355 }
356 return rawOutput;
359export class OutputPathError extends Error {
360 constructor(message, diagnostic) {
361 super(message);
362 this.name = 'OutputPathError';
363 this.archifyDiagnostics = [{
364 severity: 'error',
365 subject: {},
366 evidence: {},
367 supportedFixes: [],
368 ...diagnostic,
369 }];
370 }
373export function resolveOutputPath({
374 requestedOutput,
375 authoredOutput,
376 defaultOutput,
377 inputPaths = [],
378 inputDescription = 'an input',
379 otherOutputPaths = [],
380 cwd = process.cwd(),
381 requiredExtension = '.html',
382 platform = process.platform,
383}) {
384 if (authoredOutput !== undefined) validateAuthoredOutputPath(authoredOutput, { cwd });
385 const source = requestedOutput !== undefined
386 ? 'cli'
387 : (authoredOutput !== undefined ? 'meta' : 'default');
388 const rawOutput = source === 'cli'
389 ? requestedOutput
390 : (source === 'meta' ? authoredOutput : defaultOutput);
391 if (source !== 'meta') validateNativeOutputPath(rawOutput, { platform, kind: 'file' });
392 const outputPath = path.resolve(cwd, rawOutput);
393 for (const inputPath of inputPaths) {
394 if (!pathsAlias(outputPath, inputPath)) continue;
395 throw new OutputPathError(`Output must not replace ${inputDescription}.`, {
396 code: 'output/input-alias',
397 message: `Output must not replace ${inputDescription}, including through a symbolic-link or future-path alias.`,
398 subject: { output: outputPath, input: path.resolve(inputPath) },
399 supportedFixes: ['choose an output path that is distinct from every input path'],
400 });
401 }
402 for (const otherOutputPath of otherOutputPaths) {
403 if (!pathsAlias(outputPath, otherOutputPath)) continue;
404 throw new OutputPathError('Output targets must use distinct paths.', {
405 code: 'output/target-alias',
406 message: 'Output targets must use distinct paths, including symbolic-link and future-path aliases.',
407 subject: { output: outputPath, conflictingOutput: path.resolve(otherOutputPath) },
408 supportedFixes: ['choose distinct paths for every generated output'],
409 });
410 }
412 // Keep explicit CLI directories unrestricted, but reject mistaken file types.
413 // Alias checks above retain priority when a target would overwrite an input.
414 if (source === 'cli') {
415 const resolvedOutput = canonicalFuturePath(outputPath);
416 const authoredExtension = path.extname(rawOutput).toLowerCase();
417 const existingWindowsHtmlAlias = platform === 'win32'
418 && requiredExtension === '.html'
419 && authoredExtension === '.htm'
420 && path.extname(resolvedOutput).toLowerCase() === requiredExtension
421 && pathsAlias(outputPath, resolvedOutput);
422 const authoredMatches = authoredExtension === requiredExtension || existingWindowsHtmlAlias;
423 const resolvedMatches = path.extname(resolvedOutput).toLowerCase() === requiredExtension;
424 if (!authoredMatches || !resolvedMatches) {
425 const message = `CLI output must ${authoredMatches ? 'resolve to' : 'target'} a ${requiredExtension} file.`;
426 throw new OutputPathError(message, {
427 code: authoredMatches ? 'output/cli-resolved-extension' : 'output/cli-extension',
428 message,
429 subject: { output: rawOutput },
430 evidence: { resolvedOutput, requiredExtension },
431 supportedFixes: [`choose a path ending in ${requiredExtension} whose symbolic-link target also ends in ${requiredExtension}`],
432 });
433 }
434 }
436 return {
437 outputPath,
438 source,
439 };