create-packkit 2.8.0 → 3.0.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,451 @@
1
+ // Embedded API — the supported entry point for a Node application that wants
2
+ // to use Packkit as a project-generation engine.
3
+ //
4
+ // Everything here is pure and side-effect free except the writer (separate
5
+ // module). No prompts, no installs, no git, no network, no command execution.
6
+ // A host calls createProject() to generate in memory, extendProject() to add
7
+ // its own deployment files, and writeGeneratedProject() when it wants disk.
8
+
9
+ import { readFileSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { createHash } from 'node:crypto';
12
+
13
+ import {
14
+ generateTracked,
15
+ normalizeConfig,
16
+ resolvePreset,
17
+ PRESETS,
18
+ OPTIONS,
19
+ } from '../core/index.js';
20
+ import { finalizePackageJson } from '../core/pkg.js';
21
+ import { deepMerge, toJson } from '../core/render.js';
22
+ import { validateRelativePath, validatePathMap } from './paths.js';
23
+ import { analyzePkgFragments } from './pkg-merge.js';
24
+ import { deriveDeploymentContract } from './contract.js';
25
+
26
+ export { deriveDeploymentContract };
27
+
28
+ // Bumped when the shape of PackkitProjectDefinition changes incompatibly.
29
+ export const SCHEMA_VERSION = 2;
30
+
31
+ // Resource ceilings for definitions loaded from untrusted stores (a database,
32
+ // an upload, a queue). Generous enough for any real project, small enough to
33
+ // refuse a hostile blob before it reaches the filesystem.
34
+ const MAX_DEFINITION_FILES = 5000;
35
+ const MAX_DEFINITION_BYTES = 50_000_000;
36
+
37
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
38
+
39
+ /** Non-OPTIONS config keys the pipeline sets itself; not "unknown". */
40
+ const KNOWN_EXTRA_KEYS = new Set(['preset', 'generatorVersion']);
41
+
42
+ // Fields where a host override silently changing an existing value is worth a
43
+ // diagnostic (matches pkg-merge's protected set + dependency maps).
44
+ const PROTECTED_PKG_FIELDS = new Set(['scripts', 'exports', 'bin', 'main', 'module', 'types', 'files', 'engines', 'packageManager']);
45
+ const DEP_MAPS = new Set(['dependencies', 'devDependencies', 'peerDependencies']);
46
+
47
+ // Replay state lives off to the side, keyed by the project object, so the
48
+ // public GeneratedProject stays clean (no leaking `_extensions`), immutable to
49
+ // consumers, and never accidentally serialized into logs or API responses.
50
+ const extensionState = new WeakMap();
51
+
52
+ /** Thrown when a config or definition cannot produce a valid project. */
53
+ export class PackkitValidationError extends Error {
54
+ constructor(message, diagnostics) {
55
+ super(message);
56
+ this.name = 'PackkitValidationError';
57
+ this.code = 'PACKKIT_VALIDATION_FAILED';
58
+ this.diagnostics = diagnostics;
59
+ }
60
+ }
61
+
62
+ let cachedVersion;
63
+ function packkitVersion() {
64
+ if (cachedVersion) return cachedVersion;
65
+ try {
66
+ const url = new URL('../../package.json', import.meta.url);
67
+ cachedVersion = JSON.parse(readFileSync(fileURLToPath(url), 'utf8')).version;
68
+ } catch {
69
+ cachedVersion = '0.0.0';
70
+ }
71
+ return cachedVersion;
72
+ }
73
+
74
+ /**
75
+ * Generate a complete project in memory. No files are written, nothing is
76
+ * installed, no commands run. Returns a GeneratedProject with diagnostics.
77
+ * Throws PackkitValidationError if the config is fatally invalid.
78
+ */
79
+ export function createProject(input = {}) {
80
+ if (!input || typeof input !== 'object') throw new TypeError('createProject expects an input object.');
81
+
82
+ const merged = { ...(input.config || {}), ...(input.overrides || {}) };
83
+ if (input.name != null) merged.name = input.name;
84
+
85
+ const preErrors = validateInput(merged);
86
+ if (preErrors.length) {
87
+ throw new PackkitValidationError('The configuration is not valid; see error.diagnostics.', preErrors);
88
+ }
89
+
90
+ const diagnostics = [...unknownOptionDiagnostics(merged)];
91
+
92
+ let canonicalPreset;
93
+ if (input.preset) {
94
+ canonicalPreset = resolvePreset(input.preset);
95
+ if (!canonicalPreset) {
96
+ throw new PackkitValidationError(`Unknown preset "${input.preset}".`, [
97
+ { severity: 'error', code: 'UNKNOWN_PRESET', field: 'preset', message: `Unknown preset "${input.preset}".`, source: 'validate' },
98
+ ]);
99
+ }
100
+ }
101
+ // Normalize ONCE, with the collector, over the raw preset+overrides — a second
102
+ // pass would see values already settled and report nothing. The preset is
103
+ // folded into `raw` (not set afterwards) so it reaches provenance during this
104
+ // pass; otherwise a replayed definition, whose config carries the preset, would
105
+ // bake it into packkit.json while the original did not — and the digests diverge.
106
+ const raw = canonicalPreset
107
+ ? { ...PRESETS[canonicalPreset], ...merged, preset: canonicalPreset }
108
+ : merged;
109
+ const { config, files, summary, fileSources, fragments } = generateTracked({ ...raw, generatorVersion: packkitVersion() }, diagnostics);
110
+
111
+ for (const [path, sources] of Object.entries(fileSources)) {
112
+ if (sources.length > 1) {
113
+ diagnostics.push({
114
+ severity: 'warning',
115
+ code: 'FEATURE_FILE_COLLISION',
116
+ field: path,
117
+ message: `"${path}" was written by more than one feature (${sources.join(', ')}); the last one wins.`,
118
+ source: 'assemble',
119
+ });
120
+ }
121
+ }
122
+ diagnostics.push(...analyzePkgFragments(fragments).diagnostics);
123
+
124
+ return finish({
125
+ config,
126
+ files,
127
+ summary,
128
+ diagnostics,
129
+ metadata: { packkitVersion: packkitVersion(), schemaVersion: SCHEMA_VERSION, preset: config.preset },
130
+ deploymentContract: deriveDeploymentContract(config),
131
+ }, { files: {}, packageJson: {} });
132
+ }
133
+
134
+ /**
135
+ * Return a NEW project with the extension's files and package.json fields
136
+ * layered on. Never mutates `project`. Extension file paths and contents are
137
+ * validated; collisions with existing files follow collisionPolicy ('error'
138
+ * by default), and host overrides of existing package fields are reported.
139
+ */
140
+ export function extendProject(project, extension = {}, internal = {}) {
141
+ assertProject(project);
142
+ // Internal: a path→mode map lets definition replay preserve the ORIGINAL
143
+ // add/replace intent instead of re-deriving it against the current base.
144
+ // Without it, an `add` that now collides would be re-recorded as `replace`,
145
+ // and the drift warning would vanish after one load-and-save cycle.
146
+ const storedModes = internal.fileModes || null;
147
+ const policy = extension.collisionPolicy || 'error';
148
+ if (!['error', 'skip', 'overwrite'].includes(policy)) {
149
+ throw new TypeError(`Unknown collisionPolicy "${policy}".`);
150
+ }
151
+
152
+ const files = { ...project.files };
153
+ const diagnostics = [...project.diagnostics];
154
+ const extFiles = extension.files || {};
155
+
156
+ // Validate paths AND content up front: an invalid path or a non-string value
157
+ // anywhere means the whole extension is rejected, not half-applied.
158
+ const { diagnostics: pathDiag } = validatePathMap(extFiles);
159
+ const fatal = [...pathDiag.filter((d) => d.severity === 'error')];
160
+ for (const [path, contents] of Object.entries(extFiles)) {
161
+ if (typeof contents !== 'string') {
162
+ fatal.push({ severity: 'error', code: 'INVALID_FILE_CONTENT', field: path, message: `Contents of "${path}" must be a string.`, source: 'extend' });
163
+ }
164
+ }
165
+ if (fatal.length) throw new PackkitValidationError('Extension files are not valid; see error.diagnostics.', fatal);
166
+
167
+ const prevState = extensionState.get(project) || { files: {}, packageJson: {} };
168
+ const stateFiles = { ...prevState.files };
169
+
170
+ for (const [path, contents] of Object.entries(extFiles)) {
171
+ const target = validateRelativePath(path).normalized;
172
+ const collides = target in files;
173
+ if (collides) {
174
+ if (policy === 'error') {
175
+ throw new PackkitValidationError(`Extension file "${path}" collides with a generated file.`, [
176
+ { severity: 'error', code: 'EXTENSION_FILE_COLLISION', field: path, message: `"${path}" already exists in the generated project.`, source: 'extend' },
177
+ ]);
178
+ }
179
+ diagnostics.push({
180
+ severity: 'info',
181
+ code: policy === 'skip' ? 'EXTENSION_FILE_SKIPPED' : 'EXTENSION_FILE_OVERWRITTEN',
182
+ field: path,
183
+ message: policy === 'skip' ? `"${path}" was kept from the generated project.` : `"${path}" was replaced by the extension.`,
184
+ source: 'extend',
185
+ });
186
+ if (policy === 'skip') continue;
187
+ }
188
+ files[target] = contents;
189
+ // "add" = the host introduced a path Packkit didn't generate; "replace" =
190
+ // deliberately overriding generated output. Recorded so a stored definition
191
+ // can tell, on replay under a newer Packkit, whether an add now collides
192
+ // with a file that version started generating. A stored mode (from replay)
193
+ // wins over the freshly-computed one, so the original intent survives a
194
+ // load-and-save round-trip.
195
+ const mode = storedModes && target in storedModes ? storedModes[target] : collides ? 'replace' : 'add';
196
+ stateFiles[target] = { content: contents, mode };
197
+ }
198
+
199
+ let packageJson = prevState.packageJson;
200
+ if (extension.packageJson && Object.keys(extension.packageJson).length) {
201
+ const current = JSON.parse(files['package.json']);
202
+ diagnostics.push(...extensionPackageDiagnostics(current, extension.packageJson));
203
+ const mergedPkg = finalizePackageJson(deepMerge(current, extension.packageJson));
204
+ files['package.json'] = toJson(mergedPkg);
205
+ // Keep the raw override for definition export, minus prototype-poisoning keys.
206
+ packageJson = deepMerge(packageJson, extension.packageJson);
207
+ }
208
+
209
+ return finish({
210
+ config: project.config,
211
+ files,
212
+ summary: { ...project.summary, fileCount: Object.keys(files).length },
213
+ diagnostics,
214
+ metadata: { ...project.metadata, ...(extension.metadata ? { extension: extension.metadata } : {}) },
215
+ deploymentContract: project.deploymentContract,
216
+ }, { files: stateFiles, packageJson });
217
+ }
218
+
219
+ /**
220
+ * A serializable definition that reproduces this project later. Contains no
221
+ * secrets and no absolute paths — the config, preset, and the extension
222
+ * material the host layered on (each file tagged add/replace).
223
+ */
224
+ export function exportProjectDefinition(project) {
225
+ assertProject(project);
226
+ const state = extensionState.get(project) || { files: {}, packageJson: {} };
227
+ return {
228
+ schemaVersion: SCHEMA_VERSION,
229
+ packkitVersion: project.metadata.packkitVersion,
230
+ preset: project.metadata.preset,
231
+ config: serializableConfig(project.config),
232
+ extensions: {
233
+ files: Object.fromEntries(Object.entries(state.files).map(([p, e]) => [p, { content: e.content, mode: e.mode }])),
234
+ packageJson: { ...state.packageJson },
235
+ },
236
+ };
237
+ }
238
+
239
+ /** Rebuild a project from a stored definition, re-applying its extensions. */
240
+ export function createProjectFromDefinition(definition) {
241
+ validateDefinition(definition);
242
+ const current = packkitVersion();
243
+ const base = createProject({ preset: definition.preset, config: definition.config });
244
+
245
+ const drift = [];
246
+ if (definition.packkitVersion && definition.packkitVersion !== current) {
247
+ drift.push({
248
+ severity: 'warning',
249
+ code: 'PACKKIT_VERSION_DRIFT',
250
+ field: 'packkitVersion',
251
+ message: `Definition was created with Packkit ${definition.packkitVersion}; this is ${current}. Output may differ.`,
252
+ source: 'definition',
253
+ previousValue: definition.packkitVersion,
254
+ resolvedValue: current,
255
+ });
256
+ }
257
+
258
+ const ext = definition.extensions || {};
259
+ const extFiles = ext.files || {};
260
+ const plainFiles = {};
261
+ const storedModes = {};
262
+ for (const [path, entry] of Object.entries(extFiles)) {
263
+ const { content, mode } = normalizeDefinitionFile(entry);
264
+ plainFiles[path] = content;
265
+ storedModes[validateRelativePath(path).normalized] = mode;
266
+ // A file the host originally *added* now collides with something this
267
+ // Packkit version generates: surface it loudly instead of silently taking
268
+ // the stored copy. The definition still reproduces (the host file wins, as
269
+ // it did originally), but the drift is now visible for reconciliation.
270
+ if (mode === 'add' && base.files[path] !== undefined) {
271
+ drift.push({
272
+ severity: 'error',
273
+ code: 'EXTENSION_ADD_COLLIDES_WITH_NEW_BASE',
274
+ field: path,
275
+ message: `"${path}" was originally added by the host, but Packkit ${current} now generates it too. The stored copy was used; review the difference.`,
276
+ source: 'definition',
277
+ });
278
+ }
279
+ }
280
+
281
+ const hasExt = Object.keys(plainFiles).length || Object.keys(ext.packageJson || {}).length;
282
+ // Carry the stored modes so re-exporting the replayed project preserves the
283
+ // original add/replace intent rather than re-deriving it against this base.
284
+ const result = hasExt
285
+ ? extendProject(base, { files: plainFiles, packageJson: ext.packageJson || {}, collisionPolicy: 'overwrite' }, { fileModes: storedModes })
286
+ : base;
287
+ result.diagnostics.push(...drift);
288
+ return result;
289
+ }
290
+
291
+ /**
292
+ * A stable digest of the project's config and file contents. Two projects with
293
+ * the same Packkit version, config, and extensions produce the same digest;
294
+ * nondeterministic metadata (timestamps) is deliberately excluded.
295
+ */
296
+ export function calculateProjectDigest(project) {
297
+ assertProject(project);
298
+ const h = createHash('sha256');
299
+ h.update('config\0');
300
+ h.update(JSON.stringify(serializableConfig(project.config)));
301
+ for (const path of Object.keys(project.files).sort()) {
302
+ h.update(`\0file\0${path}\0`);
303
+ h.update(project.files[path]);
304
+ }
305
+ return h.digest('hex');
306
+ }
307
+
308
+ // ---- internals -------------------------------------------------------------
309
+
310
+ function finish(project, state) {
311
+ extensionState.set(project, state);
312
+ return project;
313
+ }
314
+
315
+ function assertProject(project) {
316
+ if (!project || typeof project !== 'object' || !project.files || !project.config) {
317
+ throw new TypeError('Expected a GeneratedProject from createProject().');
318
+ }
319
+ }
320
+
321
+ function serializableConfig(config) {
322
+ const out = {};
323
+ for (const key of Object.keys(OPTIONS)) {
324
+ if (config[key] !== undefined) out[key] = config[key];
325
+ }
326
+ if (config.preset) out.preset = config.preset;
327
+ return sortObject(out);
328
+ }
329
+
330
+ function sortObject(obj) {
331
+ return Object.fromEntries(Object.keys(obj).sort().map((k) => [k, obj[k]]));
332
+ }
333
+
334
+ function validateInput(config) {
335
+ const errors = [];
336
+ if (config.name != null && (typeof config.name !== 'string' || config.name.trim() === '')) {
337
+ errors.push({ severity: 'error', code: 'INVALID_NAME', field: 'name', message: 'name must be a non-empty string.', source: 'validate' });
338
+ }
339
+ for (const [key, value] of Object.entries(config)) {
340
+ const opt = OPTIONS[key];
341
+ if (!opt) continue;
342
+ if (opt.type === 'boolean' && typeof value !== 'boolean') {
343
+ errors.push(valueError(key, value, 'must be true or false'));
344
+ } else if (opt.choices) {
345
+ const allowed = opt.choices.map((c) => c.value);
346
+ const values = opt.type === 'multiselect' ? (Array.isArray(value) ? value : [value]) : [value];
347
+ for (const v of values) {
348
+ if (!allowed.includes(v)) errors.push(valueError(key, v, `must be one of: ${allowed.join(', ')}`));
349
+ }
350
+ }
351
+ }
352
+ return errors;
353
+ }
354
+
355
+ function valueError(field, value, detail) {
356
+ return { severity: 'error', code: 'VALUE_NOT_ALLOWED', field, previousValue: value, message: `${field} ${detail}.`, source: 'validate' };
357
+ }
358
+
359
+ function unknownOptionDiagnostics(config) {
360
+ const out = [];
361
+ for (const key of Object.keys(config)) {
362
+ if (OPTIONS[key] || KNOWN_EXTRA_KEYS.has(key)) continue;
363
+ out.push({ severity: 'warning', code: 'UNKNOWN_OPTION', field: key, message: `"${key}" is not a Packkit option and was ignored.`, source: 'validate' });
364
+ }
365
+ return out;
366
+ }
367
+
368
+ // Report host package overrides that change an existing generated value, so the
369
+ // host still wins but the change is visible (it can invalidate the deployment
370
+ // contract — e.g. redefining scripts.build).
371
+ function extensionPackageDiagnostics(current, override) {
372
+ const out = [];
373
+ for (const [topKey, value] of Object.entries(override)) {
374
+ if (DEP_MAPS.has(topKey) && current[topKey]) {
375
+ for (const [dep, version] of Object.entries(value || {})) {
376
+ const prev = current[topKey][dep];
377
+ if (prev !== undefined && prev !== version) {
378
+ out.push({ severity: 'warning', code: 'EXTENSION_DEPENDENCY_VERSION_OVERRIDE', field: `${topKey}.${dep}`, message: `The extension changed ${topKey}.${dep} from ${prev} to ${version}.`, source: 'extend', previousValue: prev, resolvedValue: version });
379
+ }
380
+ }
381
+ } else if (PROTECTED_PKG_FIELDS.has(topKey) && current[topKey] !== undefined) {
382
+ if (value && typeof value === 'object' && !Array.isArray(value) && typeof current[topKey] === 'object') {
383
+ for (const [k, v] of Object.entries(value)) {
384
+ const prev = current[topKey][k];
385
+ if (prev !== undefined && JSON.stringify(prev) !== JSON.stringify(v)) {
386
+ out.push({ severity: 'warning', code: 'EXTENSION_PACKAGE_FIELD_OVERRIDE', field: `${topKey}.${k}`, message: `The extension changed ${topKey}.${k}.`, source: 'extend', previousValue: prev, resolvedValue: v });
387
+ }
388
+ }
389
+ } else if (JSON.stringify(current[topKey]) !== JSON.stringify(value)) {
390
+ out.push({ severity: 'warning', code: 'EXTENSION_PACKAGE_FIELD_OVERRIDE', field: topKey, message: `The extension changed ${topKey}.`, source: 'extend', previousValue: current[topKey], resolvedValue: value });
391
+ }
392
+ }
393
+ }
394
+ return out;
395
+ }
396
+
397
+ function normalizeDefinitionFile(entry) {
398
+ // v2 stores { content, mode }; tolerate a bare string (mode unknown → treat as
399
+ // a deliberate replace so it never falsely reports an add-collision).
400
+ if (typeof entry === 'string') return { content: entry, mode: 'replace' };
401
+ return { content: entry.content, mode: entry.mode === 'add' ? 'add' : 'replace' };
402
+ }
403
+
404
+ // Definitions can arrive from untrusted stores, so validate structure, guard
405
+ // against prototype-pollution keys, cap resource use, and re-check every path
406
+ // before any of it can reach generation or disk.
407
+ function validateDefinition(definition) {
408
+ const errs = [];
409
+ const fail = (code, message, field) => errs.push({ severity: 'error', code, message, field, source: 'definition' });
410
+
411
+ if (!isPlainObject(definition)) throw new PackkitValidationError('A definition object is required.', [{ severity: 'error', code: 'INVALID_DEFINITION', message: 'Definition must be a plain object.', source: 'definition' }]);
412
+ if (definition.schemaVersion !== SCHEMA_VERSION) fail('SCHEMA_VERSION_MISMATCH', `Definition schemaVersion ${definition.schemaVersion} is not supported (expected ${SCHEMA_VERSION}).`, 'schemaVersion');
413
+ if (definition.config !== undefined && !isPlainObject(definition.config)) fail('INVALID_DEFINITION', 'config must be a plain object.', 'config');
414
+ if (definition.config) assertNoUnsafeKeys(definition.config, 'config', fail);
415
+
416
+ const ext = definition.extensions;
417
+ if (ext !== undefined) {
418
+ if (!isPlainObject(ext)) fail('INVALID_DEFINITION', 'extensions must be a plain object.', 'extensions');
419
+ else {
420
+ const files = ext.files || {};
421
+ if (!isPlainObject(files)) fail('INVALID_DEFINITION', 'extensions.files must be an object.', 'extensions.files');
422
+ else {
423
+ const paths = Object.keys(files);
424
+ if (paths.length > MAX_DEFINITION_FILES) fail('DEFINITION_TOO_LARGE', `Definition has ${paths.length} files (max ${MAX_DEFINITION_FILES}).`, 'extensions.files');
425
+ let total = 0;
426
+ for (const [p, entry] of Object.entries(files)) {
427
+ const content = typeof entry === 'string' ? entry : entry && entry.content;
428
+ if (typeof content !== 'string') fail('INVALID_FILE_CONTENT', `extensions.files["${p}"] must have string content.`, p);
429
+ else total += Buffer.byteLength(content, 'utf8'); // count UTF-8 bytes, matching the "bytes" limit
430
+ }
431
+ if (total > MAX_DEFINITION_BYTES) fail('DEFINITION_TOO_LARGE', `Definition content is ${total} bytes (max ${MAX_DEFINITION_BYTES}).`, 'extensions.files');
432
+ for (const d of validatePathMap(Object.fromEntries(paths.map((p) => [p, '']))).diagnostics) errs.push({ ...d, source: 'definition' });
433
+ }
434
+ if (ext.packageJson !== undefined && !isPlainObject(ext.packageJson)) fail('INVALID_DEFINITION', 'extensions.packageJson must be an object.', 'extensions.packageJson');
435
+ if (isPlainObject(ext.packageJson)) assertNoUnsafeKeys(ext.packageJson, 'extensions.packageJson', fail);
436
+ }
437
+ }
438
+
439
+ if (errs.length) throw new PackkitValidationError('The project definition is not valid; see error.diagnostics.', errs);
440
+ }
441
+
442
+ function assertNoUnsafeKeys(obj, path, fail) {
443
+ for (const key of Object.keys(obj)) {
444
+ if (UNSAFE_KEYS.has(key)) fail('UNSAFE_KEY', `"${path}.${key}" is not an allowed key.`, `${path}.${key}`);
445
+ else if (isPlainObject(obj[key])) assertNoUnsafeKeys(obj[key], `${path}.${key}`, fail);
446
+ }
447
+ }
448
+
449
+ function isPlainObject(v) {
450
+ return v != null && typeof v === 'object' && !Array.isArray(v);
451
+ }
@@ -0,0 +1,88 @@
1
+ // Path validation for generated and extension-supplied files.
2
+ //
3
+ // Every path that could reach the filesystem passes through here — once when a
4
+ // project is assembled, and again at the writer boundary. A path that escapes
5
+ // the destination, is absolute, or is otherwise unsafe is rejected rather than
6
+ // written, so a host application embedding Packkit can accept file maps from
7
+ // less-trusted sources (extensions, stored definitions) without opening a
8
+ // path-traversal hole.
9
+
10
+ import { posix } from 'node:path';
11
+
12
+ // Windows reserves these device names regardless of extension (CON.txt is still
13
+ // CON). Rejecting them keeps generated projects writable on Windows.
14
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i;
15
+
16
+ /**
17
+ * Validate a repo-relative file path. Returns { ok: true, normalized } or
18
+ * { ok: false, code, message }. Pure — never touches the filesystem.
19
+ */
20
+ export function validateRelativePath(path) {
21
+ if (typeof path !== 'string' || path.length === 0) {
22
+ return fail('EMPTY_PATH', 'A file path must be a non-empty string.');
23
+ }
24
+ if (path.includes('\0')) {
25
+ return fail('NULL_BYTE', `Path contains a null byte: ${JSON.stringify(path)}`);
26
+ }
27
+ // Treat backslashes as separators so a Windows-style path can't smuggle a
28
+ // segment past the checks below on a POSIX host.
29
+ const unified = path.replace(/\\/g, '/');
30
+ if (/^[a-zA-Z]:/.test(unified) || unified.startsWith('/')) {
31
+ return fail('ABSOLUTE_PATH', `Path must be relative, not absolute: ${path}`);
32
+ }
33
+
34
+ const normalized = posix.normalize(unified);
35
+ if (normalized === '.' || normalized === '' || normalized.endsWith('/')) {
36
+ return fail('NOT_A_FILE', `Path does not name a file: ${path}`);
37
+ }
38
+ // normalize() collapses interior `..`; anything that still starts with `..`
39
+ // (or is exactly `..`) escapes the destination.
40
+ if (normalized === '..' || normalized.startsWith('../')) {
41
+ return fail('PATH_ESCAPE', `Path escapes the destination directory: ${path}`);
42
+ }
43
+ for (const segment of normalized.split('/')) {
44
+ if (WINDOWS_RESERVED.test(segment)) {
45
+ return fail('WINDOWS_RESERVED', `Path uses a Windows-reserved name: ${path}`);
46
+ }
47
+ }
48
+ return { ok: true, normalized };
49
+ }
50
+
51
+ /**
52
+ * Validate a whole file map. Returns { paths, diagnostics } where `paths` maps
53
+ * each original key to its normalized form, and diagnostics carries one error
54
+ * per invalid path plus one per case-insensitive collision (two distinct paths
55
+ * that would be the same file on a case-insensitive filesystem).
56
+ */
57
+ export function validatePathMap(files) {
58
+ const paths = {};
59
+ const diagnostics = [];
60
+ const lowered = new Map(); // lowercased normalized path -> first original key
61
+
62
+ for (const original of Object.keys(files)) {
63
+ const res = validateRelativePath(original);
64
+ if (!res.ok) {
65
+ diagnostics.push({ severity: 'error', code: res.code, message: res.message, source: 'path', field: original });
66
+ continue;
67
+ }
68
+ paths[original] = res.normalized;
69
+
70
+ const key = res.normalized.toLowerCase();
71
+ if (lowered.has(key) && lowered.get(key) !== res.normalized) {
72
+ diagnostics.push({
73
+ severity: 'error',
74
+ code: 'CASE_INSENSITIVE_COLLISION',
75
+ message: `"${original}" and "${lowered.get(key)}" are the same file on a case-insensitive filesystem.`,
76
+ source: 'path',
77
+ field: original,
78
+ });
79
+ } else {
80
+ lowered.set(key, res.normalized);
81
+ }
82
+ }
83
+ return { paths, diagnostics };
84
+ }
85
+
86
+ function fail(code, message) {
87
+ return { ok: false, code, message };
88
+ }
@@ -0,0 +1,89 @@
1
+ // Provenance-tracked package.json analysis.
2
+ //
3
+ // The core already merges feature fragments (deepMerge, last writer wins) to
4
+ // produce the actual package.json. This module does not replace that — it runs
5
+ // alongside it to answer "who set this field, and did two contributors
6
+ // disagree?", so the embedded API can report conflicts instead of letting a
7
+ // silent overwrite hide a bug.
8
+
9
+ // Fields where two contributors setting the *same leaf* to *different values*
10
+ // is a real conflict a host should hear about. Dependency maps are handled
11
+ // separately (version-aware, per section); everything else deep-merges.
12
+ const PROTECTED = new Set(['scripts', 'exports', 'bin', 'main', 'module', 'types', 'files', 'engines', 'packageManager']);
13
+ const DEP_MAPS = new Set(['dependencies', 'devDependencies', 'peerDependencies']);
14
+
15
+ /**
16
+ * Analyze an ordered list of { source, pkg } fragments.
17
+ * Returns { diagnostics } describing every protected-field leaf that two
18
+ * sources set to conflicting values, and every dependency pinned to two
19
+ * different versions *within the same section*. Does not produce package.json.
20
+ */
21
+ export function analyzePkgFragments(fragments) {
22
+ const diagnostics = [];
23
+ const leaves = new Map(); // rendered leaf path -> { value, source }
24
+ // Keyed by section + name, so `dependencies.react` and `peerDependencies.react`
25
+ // are distinct — differing versions across those sections is normal, not a bug.
26
+ const deps = new Map();
27
+
28
+ for (const { source, pkg } of fragments) {
29
+ for (const [topKey, value] of Object.entries(pkg)) {
30
+ if (DEP_MAPS.has(topKey)) {
31
+ for (const [dep, version] of Object.entries(value || {})) {
32
+ const key = `${topKey}:${dep}`;
33
+ const prev = deps.get(key);
34
+ if (prev && prev.version !== version) {
35
+ diagnostics.push({
36
+ severity: 'warning',
37
+ code: 'DEPENDENCY_VERSION_CONFLICT',
38
+ field: `${topKey}.${dep}`,
39
+ message: `"${dep}" in ${topKey} is requested at ${prev.version} (by ${prev.source}) and ${version} (by ${source}).`,
40
+ source: 'package-merge',
41
+ previousValue: prev.version,
42
+ resolvedValue: version,
43
+ });
44
+ }
45
+ deps.set(key, { version, source });
46
+ }
47
+ continue;
48
+ }
49
+ if (PROTECTED.has(topKey)) {
50
+ collectLeaves([topKey], value, (segments, leafValue) => {
51
+ const rendered = renderPath(segments);
52
+ const prev = leaves.get(rendered);
53
+ if (prev && JSON.stringify(prev.value) !== JSON.stringify(leafValue)) {
54
+ diagnostics.push({
55
+ severity: 'warning',
56
+ code: 'PACKAGE_FIELD_CONFLICT',
57
+ field: rendered,
58
+ message: `"${rendered}" is set to different values by ${prev.source} and ${source}; the later one wins.`,
59
+ source: 'package-merge',
60
+ previousValue: prev.value,
61
+ resolvedValue: leafValue,
62
+ });
63
+ }
64
+ leaves.set(rendered, { value: leafValue, source });
65
+ });
66
+ }
67
+ }
68
+ }
69
+ return { diagnostics };
70
+ }
71
+
72
+ // Recursively flatten a protected field to its leaf values, carrying the key
73
+ // path as an array so a key that itself contains a dot (e.g. exports './sub.js')
74
+ // isn't mistaken for two segments.
75
+ function collectLeaves(segments, value, emit) {
76
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
77
+ for (const [k, v] of Object.entries(value)) collectLeaves([...segments, k], v, emit);
78
+ } else {
79
+ emit(segments, value);
80
+ }
81
+ }
82
+
83
+ // Render a segment path for human-facing diagnostics. Dotted segments get
84
+ // bracket notation so the field reads unambiguously.
85
+ function renderPath(segments) {
86
+ return segments
87
+ .map((s, i) => (i > 0 && s.includes('.') ? `['${s}']` : i > 0 ? `.${s}` : s))
88
+ .join('');
89
+ }