@effected/package-json 0.11.0 → 0.13.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/Funding.js ADDED
@@ -0,0 +1,146 @@
1
+ import { Effect, Schema, SchemaTransformation } from "effect";
2
+
3
+ //#region src/Funding.ts
4
+ const entryWires = /* @__PURE__ */ new WeakMap();
5
+ /**
6
+ * Entries that were the WHOLE field, written bare rather than inside an array.
7
+ *
8
+ * Keyed by the ENTRY, not by the decoded array: `Schema.Array` rebuilds the
9
+ * array on the way out of the transform, so an array-keyed WeakMap is empty by
10
+ * the time `encode` runs — verified by the arity round trip failing under it.
11
+ * Arity provenance therefore rides the one instance that was the field, and
12
+ * the replay is guarded on that instance still being alone.
13
+ */
14
+ const bareEntries = /* @__PURE__ */ new WeakSet();
15
+ const KNOWN_FUNDING_KEYS = /* @__PURE__ */ new Set(["type", "url"]);
16
+ const FundingFields = Schema.Struct({
17
+ type: Schema.optionalKey(Schema.String),
18
+ url: Schema.String
19
+ });
20
+ const decodeFundingFields = Schema.decodeUnknownEffect(FundingFields);
21
+ const restOf = (raw) => {
22
+ const rest = {};
23
+ for (const [key, value] of Object.entries(raw)) if (!KNOWN_FUNDING_KEYS.has(key)) rest[key] = value;
24
+ return rest;
25
+ };
26
+ /**
27
+ * Whether the remembered object still describes this entry. Guarding the
28
+ * replay is load-bearing: `Schema.Class` instances are not frozen, so an entry
29
+ * mutated in place keeps a provenance entry that no longer describes it, and
30
+ * an unguarded replay would write the ORIGINAL object back — silently
31
+ * discarding the edit.
32
+ */
33
+ const isFaithfulObject = (wire, funding) => {
34
+ if (wire.url !== funding.url) return false;
35
+ if ((typeof wire.type === "string" ? wire.type : void 0) !== funding.type) return false;
36
+ for (const [key, value] of Object.entries(funding.rest ?? {})) if (wire[key] !== value) return false;
37
+ return true;
38
+ };
39
+ /** Whether the bare string form can still carry everything this entry holds. */
40
+ const isStringExpressible = (funding) => funding.type === void 0 && Object.keys(funding.rest ?? {}).length === 0;
41
+ const encodeEntry = (funding) => {
42
+ const wire = entryWires.get(funding);
43
+ if (typeof wire === "string" && isStringExpressible(funding)) return funding.url;
44
+ if (wire !== void 0 && typeof wire !== "string" && isFaithfulObject(wire, funding)) return wire;
45
+ return {
46
+ ...funding.type !== void 0 && { type: funding.type },
47
+ url: funding.url,
48
+ ...funding.rest
49
+ };
50
+ };
51
+ const decodeEntry = (input) => {
52
+ if (typeof input === "string") {
53
+ const funding = Funding.make({ url: input });
54
+ entryWires.set(funding, input);
55
+ return Effect.succeed(funding);
56
+ }
57
+ return decodeFundingFields(input).pipe(Effect.mapError((error) => error.issue), Effect.map((fields) => {
58
+ const rest = restOf(input);
59
+ const funding = Funding.make({
60
+ ...fields,
61
+ ...Object.keys(rest).length > 0 ? { rest } : {}
62
+ });
63
+ entryWires.set(funding, input);
64
+ return funding;
65
+ }));
66
+ };
67
+ const EntryValue = Schema.Union([Schema.Record(Schema.String, Schema.Unknown), Schema.String]);
68
+ const FieldValue = Schema.Union([EntryValue, Schema.Array(EntryValue)]);
69
+ /**
70
+ * Where to send money for a package: one funding entry.
71
+ *
72
+ * @remarks
73
+ * npm's `funding` field accepts a bare URL string, this object form, or an
74
+ * array of either. `url` is **required** — it is the only thing the field
75
+ * actually says — so an object without one fails to decode rather than
76
+ * producing a half-populated entry. `type` (`"individual"`, `"github"`, …) is
77
+ * caller data and is kept **verbatim**, never normalized.
78
+ *
79
+ * @example
80
+ * ```ts
81
+ * import { Funding } from "@effected/package-json";
82
+ * import { Effect, Schema } from "effect";
83
+ *
84
+ * const program = Effect.gen(function* () {
85
+ * // Always an array, whichever encoding the manifest used.
86
+ * const entries = yield* Schema.decodeUnknownEffect(Funding.FromField)("https://example.com/sponsor");
87
+ * console.log(entries[0]?.url); // "https://example.com/sponsor"
88
+ * });
89
+ * ```
90
+ *
91
+ * @public
92
+ */
93
+ var Funding = class Funding extends Schema.Class("Funding")({
94
+ /** The funding platform, when the object form carried one (`"github"`, …). */
95
+ type: Schema.optionalKey(Schema.String),
96
+ /** Where the money goes, exactly as the manifest wrote it. */
97
+ url: Schema.String,
98
+ /** Keys outside the documented set, preserved so encoding does not drop them. */
99
+ rest: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown))
100
+ }) {
101
+ /**
102
+ * A single `funding` entry: the bare URL string or the object form, always
103
+ * decoded to a {@link Funding} and always re-encoded in the form it was read
104
+ * from.
105
+ *
106
+ * @remarks
107
+ * Provenance belongs to the instance, so an entry that is *rebuilt* rather
108
+ * than carried through has none and encodes in the canonical object form.
109
+ */
110
+ static FromValue = EntryValue.pipe(Schema.decodeTo(Schema.instanceOf(Funding), SchemaTransformation.transformOrFail({
111
+ decode: (input) => decodeEntry(input),
112
+ encode: (funding) => Effect.succeed(encodeEntry(funding))
113
+ })));
114
+ /**
115
+ * The `funding` field: a lone entry or an array of them, **always** decoded
116
+ * to an array so a consumer never branches on arity.
117
+ *
118
+ * @remarks
119
+ * The normalization is one-directional. A field written bare re-encodes
120
+ * bare, not as a one-element array — the arity is remembered against the
121
+ * single entry that WAS the field, and the replay is guarded on that entry
122
+ * still being alone, so pushing a second entry into the decoded array in
123
+ * place upgrades the field to the array form instead of silently dropping
124
+ * the addition. An entry built by hand has no provenance, so an array of
125
+ * such entries encodes as an array.
126
+ */
127
+ static FromField = FieldValue.pipe(Schema.decodeTo(Schema.Array(Schema.instanceOf(Funding)), SchemaTransformation.transformOrFail({
128
+ decode: (input) => {
129
+ const bare = !Array.isArray(input);
130
+ const values = bare ? [input] : input;
131
+ return Effect.map(Effect.forEach(values, decodeEntry), (entries) => {
132
+ const only = entries[0];
133
+ if (bare && only !== void 0) bareEntries.add(only);
134
+ return entries;
135
+ });
136
+ },
137
+ encode: (entries) => {
138
+ const only = entries.length === 1 ? entries[0] : void 0;
139
+ if (only !== void 0 && bareEntries.has(only)) return Effect.succeed(encodeEntry(only));
140
+ return Effect.succeed(entries.map(encodeEntry));
141
+ }
142
+ })));
143
+ };
144
+
145
+ //#endregion
146
+ export { Funding };
@@ -0,0 +1,261 @@
1
+ import { ExportsField, PackageDecodeError, PublishConfigField } from "./Package.js";
2
+ import { PackageJsonSyntaxError } from "./PackageJsonFormat.js";
3
+ import { Cause, Effect, Exit, Result, Schema } from "effect";
4
+
5
+ //#region src/LenientManifest.ts
6
+ const LenientFieldIssueSchema = Schema.Struct({
7
+ field: Schema.String,
8
+ expected: Schema.String,
9
+ value: Schema.Unknown
10
+ });
11
+ const StringRecord = Schema.Record(Schema.String, Schema.String);
12
+ const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
13
+ const StringOrRecord = Schema.Union([Schema.String, UnknownRecord]);
14
+ const isString = (value) => typeof value === "string";
15
+ const isBoolean = (value) => typeof value === "boolean";
16
+ const isPlainRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
17
+ const isStringRecord = (value) => isPlainRecord(value) && Object.values(value).every(isString);
18
+ const isStringArray = (value) => Array.isArray(value) && value.every(isString);
19
+ const isStringOrRecord = (value) => isString(value) || isPlainRecord(value);
20
+ const isStringOrRecordArray = (value) => Array.isArray(value) && value.every(isStringOrRecord);
21
+ const stringGuard = {
22
+ expected: "a string",
23
+ test: isString
24
+ };
25
+ const stringRecordGuard = {
26
+ expected: "an object of string values",
27
+ test: isStringRecord
28
+ };
29
+ const stringOrRecordGuard = {
30
+ expected: "a string or an object",
31
+ test: isStringOrRecord
32
+ };
33
+ const recordGuard = {
34
+ expected: "an object",
35
+ test: isPlainRecord
36
+ };
37
+ const FIELD_GUARDS = /* @__PURE__ */ new Map([
38
+ ["name", stringGuard],
39
+ ["version", stringGuard],
40
+ ["description", stringGuard],
41
+ ["private", {
42
+ expected: "a boolean",
43
+ test: isBoolean
44
+ }],
45
+ ["type", stringGuard],
46
+ ["main", stringGuard],
47
+ ["license", stringGuard],
48
+ ["author", stringOrRecordGuard],
49
+ ["contributors", {
50
+ expected: "an array of strings or objects",
51
+ test: isStringOrRecordArray
52
+ }],
53
+ ["maintainers", {
54
+ expected: "an array of strings or objects",
55
+ test: isStringOrRecordArray
56
+ }],
57
+ ["keywords", {
58
+ expected: "an array of strings",
59
+ test: isStringArray
60
+ }],
61
+ ["repository", stringOrRecordGuard],
62
+ ["bugs", stringOrRecordGuard],
63
+ ["funding", {
64
+ expected: "a string, an object, or an array of either",
65
+ test: (value) => isStringOrRecord(value) || isStringOrRecordArray(value)
66
+ }],
67
+ ["homepage", stringGuard],
68
+ ["dependencies", stringRecordGuard],
69
+ ["devDependencies", stringRecordGuard],
70
+ ["peerDependencies", stringRecordGuard],
71
+ ["optionalDependencies", stringRecordGuard],
72
+ ["peerDependenciesMeta", recordGuard],
73
+ ["scripts", stringRecordGuard],
74
+ ["bin", {
75
+ expected: "a string or an object of string values",
76
+ test: (v) => isString(v) || isStringRecord(v)
77
+ }],
78
+ ["engines", stringRecordGuard],
79
+ ["exports", stringOrRecordGuard],
80
+ ["publishConfig", recordGuard],
81
+ ["packageManager", stringGuard],
82
+ ["devEngines", recordGuard]
83
+ ]);
84
+ const sift = (raw) => {
85
+ const known = {};
86
+ const rest = Object.create(null);
87
+ const issues = [];
88
+ for (const [key, value] of Object.entries(raw)) {
89
+ const guard = FIELD_GUARDS.get(key);
90
+ if (guard === void 0) rest[key] = value;
91
+ else if (guard.test(value)) known[key] = value;
92
+ else {
93
+ rest[key] = value;
94
+ issues.push({
95
+ field: key,
96
+ expected: guard.expected,
97
+ value
98
+ });
99
+ }
100
+ }
101
+ return LenientManifest.make({
102
+ ...known,
103
+ rest,
104
+ issues
105
+ });
106
+ };
107
+ const decodeRecord = Schema.decodeUnknownExit(UnknownRecord);
108
+ /**
109
+ * The shape-lenient view of a package.json document, for discovery and
110
+ * sniffing — probing a fetched tarball's manifest, walking a `node_modules`
111
+ * tree, listing candidate packages — where the document is other people's
112
+ * data and one malformed field must not fail the read.
113
+ *
114
+ * @remarks
115
+ * **This is the discovery tier, not a validation bypass.** Every field shares
116
+ * its name with the strict `Package` model, but is typed as its plain permissive JSON
117
+ * shape: `name` and `version` are any string (a legacy uppercase name or a
118
+ * non-semver `"1.0"` is recovered, not rejected), `license` is any string (no
119
+ * SPDX check), the dependency maps and `scripts` are plain string→string
120
+ * records rather than `HashMap`s. A present field that is not even that shape
121
+ * **degrades to absence** rather than failing the document: the raw value is
122
+ * preserved verbatim in `rest` and the degradation is reported on `issues`,
123
+ * so callers can surface what was ignored. Degradation granularity is the
124
+ * top-level field — one junk entry degrades its whole map, with the raw map
125
+ * still in `rest`.
126
+ *
127
+ * Leniency is per-field, never per-syntax: text that is not valid JSON fails
128
+ * {@link LenientManifest.parseResult} as a typed
129
+ * {@link PackageJsonSyntaxError}, and a value that is not a JSON object fails
130
+ * {@link LenientManifest.decodeResult} as a typed {@link PackageDecodeError}.
131
+ *
132
+ * An empty `issues` array does **not** mean the strict tiers would accept the
133
+ * document — the permissive shapes check JSON shape, not npm semantics. The
134
+ * upgrade path is to decode the *original* input through
135
+ * `PackageManifest.decode` (presence-lenient, shape-strict) or
136
+ * `Package.decode` (strict, publishable) when validation is actually
137
+ * wanted. This class deliberately carries no mutation statics and no write
138
+ * path; editing belongs to the strict tiers and to
139
+ * `PackageJsonFormat.modifyToString` / `PackageJsonFile.modify`.
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * import { LenientManifest } from "@effected/package-json";
144
+ * import { Effect } from "effect";
145
+ *
146
+ * const program = Effect.gen(function* () {
147
+ * const sniffed = yield* LenientManifest.decode({ name: "JSONStream", version: "1.0", license: 42 });
148
+ * console.log(sniffed.name, sniffed.version); // "JSONStream" "1.0"
149
+ * console.log(sniffed.issues); // [{ field: "license", expected: "a string", value: 42 }]
150
+ * console.log(sniffed.rest?.license); // 42 — degraded, preserved verbatim
151
+ * });
152
+ * ```
153
+ *
154
+ * @public
155
+ */
156
+ var LenientManifest = class LenientManifest extends Schema.Class("LenientManifest")({
157
+ name: Schema.optionalKey(Schema.String),
158
+ version: Schema.optionalKey(Schema.String),
159
+ description: Schema.optionalKey(Schema.String),
160
+ private: Schema.optionalKey(Schema.Boolean),
161
+ type: Schema.optionalKey(Schema.String),
162
+ main: Schema.optionalKey(Schema.String),
163
+ license: Schema.optionalKey(Schema.String),
164
+ author: Schema.optionalKey(StringOrRecord),
165
+ contributors: Schema.optionalKey(Schema.Array(StringOrRecord)),
166
+ maintainers: Schema.optionalKey(Schema.Array(StringOrRecord)),
167
+ keywords: Schema.optionalKey(Schema.Array(Schema.String)),
168
+ repository: Schema.optionalKey(StringOrRecord),
169
+ bugs: Schema.optionalKey(StringOrRecord),
170
+ funding: Schema.optionalKey(Schema.Union([StringOrRecord, Schema.Array(StringOrRecord)])),
171
+ homepage: Schema.optionalKey(Schema.String),
172
+ dependencies: Schema.optionalKey(StringRecord),
173
+ devDependencies: Schema.optionalKey(StringRecord),
174
+ peerDependencies: Schema.optionalKey(StringRecord),
175
+ optionalDependencies: Schema.optionalKey(StringRecord),
176
+ peerDependenciesMeta: Schema.optionalKey(UnknownRecord),
177
+ scripts: Schema.optionalKey(StringRecord),
178
+ bin: Schema.optionalKey(Schema.Union([Schema.String, StringRecord])),
179
+ engines: Schema.optionalKey(StringRecord),
180
+ exports: Schema.optionalKey(ExportsField),
181
+ publishConfig: Schema.optionalKey(PublishConfigField),
182
+ packageManager: Schema.optionalKey(Schema.String),
183
+ devEngines: Schema.optionalKey(UnknownRecord),
184
+ /**
185
+ * Unknown top-level keys, plus every degraded known field's raw value,
186
+ * verbatim. Always present after a lenient decode (possibly empty).
187
+ */
188
+ rest: Schema.optionalKey(UnknownRecord),
189
+ /** The degradations collected by the decode — empty when nothing degraded. */
190
+ issues: Schema.Array(LenientFieldIssueSchema)
191
+ }) {
192
+ /**
193
+ * Decode an unknown JSON value leniently, degrading malformed fields instead
194
+ * of failing the document. The sync primitive backing
195
+ * {@link LenientManifest.decode}.
196
+ *
197
+ * @param input - the parsed package.json JSON value (e.g. from `JSON.parse`)
198
+ * @returns the lenient manifest, or a {@link PackageDecodeError} when
199
+ * `input` is not a JSON object at all (`null`, an array or a scalar) — the
200
+ * one failure leniency does not cover
201
+ */
202
+ static decodeResult(input) {
203
+ const exit = decodeRecord(input);
204
+ if (Exit.isFailure(exit)) return Result.fail(new PackageDecodeError({ cause: Cause.squash(exit.cause) }));
205
+ return Result.succeed(sift(exit.value));
206
+ }
207
+ /**
208
+ * Decode an unknown JSON value leniently, degrading malformed fields instead
209
+ * of failing the document. The `Effect` form of
210
+ * {@link LenientManifest.decodeResult}, adding the tracing span.
211
+ *
212
+ * @param input - the parsed package.json JSON value (e.g. from `JSON.parse`)
213
+ * @returns an Effect resolving to the decoded {@link LenientManifest}
214
+ * @throws (typed) `PackageDecodeError` when `input` is not a JSON object
215
+ */
216
+ static decode = Effect.fn("LenientManifest.decode")((input) => Effect.fromResult(LenientManifest.decodeResult(input)));
217
+ /**
218
+ * Parse package.json text and decode it leniently. The sync primitive
219
+ * backing {@link LenientManifest.parse}.
220
+ *
221
+ * @param text - the package.json source text
222
+ * @returns the lenient manifest, or a {@link PackageJsonSyntaxError} when
223
+ * the text is not valid JSON (`"invalid-json"`) or parses to something
224
+ * other than a JSON object (`"not-an-object"`) — leniency is per-field,
225
+ * never per-syntax
226
+ */
227
+ static parseResult(text) {
228
+ let raw;
229
+ try {
230
+ raw = JSON.parse(text);
231
+ } catch (cause) {
232
+ return Result.fail(new PackageJsonSyntaxError({
233
+ reason: "invalid-json",
234
+ cause
235
+ }));
236
+ }
237
+ if (!isPlainRecord(raw)) return Result.fail(new PackageJsonSyntaxError({ reason: "not-an-object" }));
238
+ return Result.succeed(sift(raw));
239
+ }
240
+ /**
241
+ * Parse package.json text and decode it leniently. The `Effect` form of
242
+ * {@link LenientManifest.parseResult}, adding the tracing span.
243
+ *
244
+ * @param text - the package.json source text
245
+ * @returns an Effect resolving to the decoded {@link LenientManifest}
246
+ * @throws (typed) `PackageJsonSyntaxError` when the text is not valid JSON
247
+ * or is not a JSON object
248
+ */
249
+ static parse = Effect.fn("LenientManifest.parse")((text) => Effect.fromResult(LenientManifest.parseResult(text)));
250
+ /** Whether the manifest is marked private. */
251
+ get isPrivate() {
252
+ return this.private ?? false;
253
+ }
254
+ /** Whether the manifest declares ESM (`"type": "module"`, exact comparison). */
255
+ get isESM() {
256
+ return this.type === "module";
257
+ }
258
+ };
259
+
260
+ //#endregion
261
+ export { LenientManifest };
package/License.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Schema } from "effect";
2
- import { isValidExpression } from "@effected/spdx";
1
+ import { Result, Schema } from "effect";
2
+ import { SpdxExpression, isValidExpression } from "@effected/spdx";
3
3
 
4
4
  //#region src/License.ts
5
5
  /**
@@ -32,9 +32,54 @@ const isValidSpdx = (value) => {
32
32
  * A valid SPDX license identifier, expression, `UNLICENSED`, or
33
33
  * `SEE LICENSE IN <file>`.
34
34
  *
35
+ * @remarks
36
+ * **A branded value here is not necessarily parseable as SPDX.** npm's
37
+ * `license` field admits two strings that the SPDX grammar does not —
38
+ * `UNLICENSED` and `SEE LICENSE IN <file>` — and this brand accepts both,
39
+ * because it models what a manifest may legally carry, not what SPDX defines.
40
+ * Feeding a branded value straight to `SpdxExpression.parse` therefore fails
41
+ * on exactly those two forms. Do not hand-screen for them — reach for
42
+ * {@link licenseExpressionOf}, which answers "what expression is this, if any"
43
+ * and yields `Option.none()` for a spelling that is not one. Reach for
44
+ * {@link isValidSpdx} when the question is instead "may a manifest carry this".
45
+ *
35
46
  * @public
36
47
  */
37
48
  const SpdxLicense = Schema.String.pipe(Schema.check(Schema.makeFilter((value) => isValidSpdx(value) ? void 0 : "Expected a valid SPDX license expression")), Schema.brand("SpdxLicense"));
49
+ /**
50
+ * The parsed SPDX expression a manifest's `license` denotes, or `Option.none()`
51
+ * when it denotes no expression at all.
52
+ *
53
+ * @remarks
54
+ * This is the accessor to reach for whenever a branded `SpdxLicense` has
55
+ * to become an actual expression — a license URL, a badge, structured data, a
56
+ * policy check. It exists because the brand and the grammar disagree, and that
57
+ * disagreement is knowledge these two packages jointly own rather than
58
+ * something each consumer should rediscover.
59
+ *
60
+ * `UNLICENSED` and `SEE LICENSE IN <file>` are legal in a manifest and are not
61
+ * SPDX expressions, so they yield `Option.none()`. Everything else parses.
62
+ * A consumer screening for those two spellings by hand gets it wrong the day
63
+ * npm admits a third — this accessor is the mechanism that prose could not be,
64
+ * and it needs no change on that day, because "not an expression" is answered
65
+ * by the grammar rather than by a list of spellings kept in step with npm.
66
+ *
67
+ * @example
68
+ * ```ts
69
+ * import { licenseExpressionOf } from "@effected/package-json";
70
+ * import { SpdxExpression } from "@effected/spdx";
71
+ *
72
+ * // "MIT" => Option.some(<LicenseNode MIT>)
73
+ * // "UNLICENSED" => Option.none()
74
+ * // "SEE LICENSE IN LICENSE.txt" => Option.none()
75
+ * ```
76
+ *
77
+ * @param license - a branded manifest license value
78
+ * @returns the parsed expression, or none for a spelling that is not one
79
+ *
80
+ * @public
81
+ */
82
+ const licenseExpressionOf = (license) => Result.getSuccess(SpdxExpression.parseResult(license));
38
83
 
39
84
  //#endregion
40
- export { InvalidSpdxLicenseError, SpdxLicense, isValidSpdx };
85
+ export { InvalidSpdxLicenseError, SpdxLicense, isValidSpdx, licenseExpressionOf };
package/Package.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { Dependency } from "./Dependency.js";
2
2
  import { DevEnginesSchema } from "./DevEngines.js";
3
- import { InvalidSpdxLicenseError, SpdxLicense, isValidSpdx } from "./License.js";
3
+ import { Funding } from "./Funding.js";
4
4
  import { renderJson, resolveFormatOptions } from "./internal/format.js";
5
5
  import { makeWire } from "./internal/wire.js";
6
+ import { InvalidSpdxLicenseError, SpdxLicense, isValidSpdx } from "./License.js";
6
7
  import { PackageManager } from "./PackageManager.js";
7
8
  import { InvalidPackageNameError, PackageName } from "./PackageName.js";
8
9
  import { Person } from "./Person.js";
@@ -121,6 +122,7 @@ var Package = class Package extends Schema.Class("Package")({
121
122
  keywords: Schema.optionalKey(Schema.Array(Schema.String)),
122
123
  repository: Schema.optionalKey(Repository.FromValue),
123
124
  bugs: Schema.optionalKey(Bugs.FromValue),
125
+ funding: Schema.optionalKey(Funding.FromField),
124
126
  homepage: Schema.optionalKey(Schema.String),
125
127
  dependencies: DependencyMapField,
126
128
  devDependencies: DependencyMapField,
@@ -23,10 +23,12 @@ import { SemVer } from "@effected/semver";
23
23
  * typed), a present `name` must still satisfy the npm grammar, a present
24
24
  * `packageManager` must still parse — though here the version position may be
25
25
  * a semver range (`pnpm@^11.20.0`), decoded as {@link PackageManagerRange}.
26
- * For total tolerance of malformed fields, use the decode-free
27
- * {@link PackageJsonFormat} text path (or `@effected/npm`'s shape-blind
28
- * `Manifest`)silently carrying a value the type claims to have validated
29
- * would be a lie, and silently dropping it would break round-trip fidelity.
26
+ * For tolerance of malformed fields, use the shape-lenient `LenientManifest`
27
+ * discovery tier which degrades them to absence, preserved in `rest` and
28
+ * reported on `issues` — or the decode-free {@link PackageJsonFormat} text
29
+ * path (or `@effected/npm`'s shape-blind `Manifest`); here, silently carrying
30
+ * a value the type claims to have validated would be a lie, and silently
31
+ * dropping it would break round-trip fidelity.
30
32
  *
31
33
  * The model is deliberately lean — fields, the `rest` catch-all wire codec,
32
34
  * {@link PackageManifest.decode} and {@link PackageManifest.toJsonString} —
package/README.md CHANGED
@@ -184,6 +184,32 @@ console.log(Effect.runSync(program));
184
184
 
185
185
  The `workspace:` range modifier is honored: `workspace:*` takes the bare version, `workspace:^` and `workspace:~` prefix it, and an explicit modifier is used as-is. The projection is `@effected/npm`'s `DependencySpecifier` statics with full pnpm publish semantics: the alias form `workspace:<name>@<range>` resolves the *target* package's version and becomes the `npm:<name>@<range>` alias pnpm publishes, and a blank catalog name selects the default catalog. A failed catalog assembly surfaces typed as `@effected/npm`'s `CatalogAssemblyError`, alongside the contracts' `DependencyResolutionError`.
186
186
 
187
+ ## Lenient discovery
188
+
189
+ `Package.decode` and `PackageManifest` are strict: a malformed field fails the whole document. That is the right behavior for a manifest you are about to write or publish, and the wrong one for a manifest you are only sniffing — a fetched tarball, a `node_modules` walk, a registry response — where the document is someone else's data and one bad field should not sink the read. `LenientManifest` is that discovery tier: every `Package` field decodes to its plain permissive JSON shape (`name` and `version` accept any string, not the branded npm grammar; `license` accepts any string, no SPDX check; the dependency maps are plain records, not `HashMap`s). A field present but not even that shape degrades to absence instead of failing the document, its raw value is preserved verbatim in `rest` (a malformed known field is treated exactly like an unknown one), and the degradation is reported on `issues`:
190
+
191
+ ```ts
192
+ import { LenientManifest } from "@effected/package-json";
193
+ import { Effect } from "effect";
194
+
195
+ const program = Effect.gen(function* () {
196
+ const sniffed = yield* LenientManifest.decode({ name: "JSONStream", version: "1.0", license: 42 });
197
+ return [sniffed.name, sniffed.version, sniffed.issues, sniffed.rest?.license] as const;
198
+ });
199
+
200
+ console.log(Effect.runSync(program));
201
+ // [
202
+ // "JSONStream",
203
+ // "1.0",
204
+ // [{ field: "license", expected: "a string", value: 42 }],
205
+ // 42,
206
+ // ]
207
+ ```
208
+
209
+ Leniency is per-field, never per-syntax: `decodeResult`/`decode` still fail typed with `PackageDecodeError` when the input is not a JSON object at all (`null`, an array, a scalar), and `parseResult`/`parse` — the pair that also handles the raw `JSON.parse` — fail typed with `PackageJsonSyntaxError` when the text is not valid JSON or does not parse to an object. Each pair follows the package's usual shape: `decodeResult`/`parseResult` are the synchronous `Result` primitives, `decode`/`parse` are their `Effect` forms with a tracing span.
210
+
211
+ An empty `issues` array is not a validity guarantee — the permissive shapes check JSON shape, not npm semantics, so a `LenientManifest` with no issues can still fail `Package.decode`. `LenientManifest` carries no mutation statics and no write path; once you need to validate or edit, re-decode the *original* input through `PackageManifest.decode` (presence-lenient, shape-on-presence strict) or `Package.decode` (strict, publishable).
212
+
187
213
  ## Resolving an entry point
188
214
 
189
215
  `resolveEntryPoint` answers one question about a manifest — which file is the package's `"."` entry — and it is pure, IO-free and `Result`-returning, so it works against a plain object with no package on disk:
@@ -236,8 +262,12 @@ Every failure is a `Schema.TaggedError` routed with `Effect.catchTag`. Causes ar
236
262
  - `PackageJsonFile` — the IO surface: `read` and `write` over core `FileSystem` / `Path`, with the platform implementation supplied at the edge.
237
263
  - `PackageValidator` — rule-based validation aggregating every failure, with the default rule set, a parameterized `layerRules` factory, and the publish-gate rules `noUnresolvedDepsRule` and `noLocalDepsRule`.
238
264
  - `resolveEntryPoint` — the pure, `Result`-returning entry-point resolver over a manifest's `exports`/`main`, honoring `exports` encapsulation rather than falling through to `main`, with `EntryPointManifest` as its tolerant input shape.
265
+ - `LenientManifest` — the shape-lenient discovery tier below `PackageManifest`: malformed known fields degrade to absence, are preserved verbatim in `rest` and reported on `issues`, rather than failing the document; `decodeResult`/`decode` and `parseResult`/`parse` in the package's usual `Result`/`Effect` pairing.
239
266
  - `Package.resolve` — `catalog:` and `workspace:` expansion over the `@effected/npm` contracts with pnpm's publish-time projection (alias form included), as an explicit step that `write` never performs for you.
240
- - `PackageName`, `DependencySpecifier`, `Dependency`, `SpdxLicense`, `PackageManager`, `Person`, `Repository`, `Bugs`, `DevEngine` — the leaf concepts, each owning its own statics, brand and error, usable independently of `Package`. `Repository` and `Bugs` decode the `repository` and `bugs` fields from either their shorthand or object form, the same way `Person` does for `author`, `contributors` and `maintainers`; `Repository` also exposes `browseUrl` and `gitUrl` getters that normalize a shorthand or SSH form to `https://`.
267
+ - `PackageName`, `DependencySpecifier`, `Dependency`, `SpdxLicense`, `PackageManager`, `Person`, `Repository`, `Bugs`, `Funding`, `DevEngine` — the leaf concepts, each owning its own statics, brand and error, usable independently of `Package`. `Repository` and `Bugs` decode the `repository` and `bugs` fields from either their shorthand or object form, the same way `Person` does for `author`, `contributors` and `maintainers`; `Repository` also exposes `browseUrl` and `gitUrl` getters that normalize a shorthand or SSH form to `https://`.
268
+ - `Repository.directoryUrl` — the browse URL of a monorepo member's own subdirectory, built from `repository.directory` against the host's path convention on GitHub, GitLab and Bitbucket. `none` for a host whose convention is unknown, or for a `directory` that climbs out of the repository — a guessed path would resolve to nothing while looking authoritative. With no `directory`, it is `browseUrl`.
269
+ - `Funding` — the `funding` field, decoded from a bare URL string, an object, or an array of either, and **always** to an array so a consumer never branches on arity. Each entry re-encodes in the form it was read from, and unrecognized keys survive in `rest`.
270
+ - `licenseExpressionOf` — a `SpdxLicense` as an `Option<SpdxExpression>` from `@effected/spdx`, for a caller that wants the parsed expression rather than the string. `none` for the `SEE LICENSE IN …` and `UNLICENSED` forms, which are valid `license` values but not license expressions.
241
271
  - `Package` also types `keywords`, `maintainers` and `homepage` directly, alongside the existing `author` and `contributors`.
242
272
  - Field schemas (`DependencyMapField`, `BinField`, `ExportsField`, `PublishConfigField`, `PeerDependenciesMetaField`, `StringMapField`) exported for subclasses that extend the model. `RepositoryField` is exported too but deprecated: `Package.repository` now decodes through `Repository.FromValue`, which round-trips the original shorthand or object form; `RepositoryField` remains only for consumers still matching on the raw union.
243
273
 
package/Repository.js CHANGED
@@ -13,6 +13,16 @@ const BARE_SHORTHAND = /^[\w.-]+\/[\w.-]+$/;
13
13
  const PREFIXED_SHORTHAND = /^([a-z]+):(.+)$/;
14
14
  /** The scp-like form git accepts: `git@host:owner/name.git`. */
15
15
  const SCP_LIKE = /^(?:([\w.-]+)@)?([\w.-]+):(.+)$/;
16
+ /**
17
+ * How each known forge spells "browse this subdirectory". `HEAD` is used
18
+ * rather than a branch name because the default branch is not knowable from a
19
+ * manifest, and every one of these hosts resolves `HEAD` to it.
20
+ */
21
+ const DIRECTORY_PATHS = /* @__PURE__ */ new Map([
22
+ ["github.com", "tree/HEAD"],
23
+ ["gitlab.com", "-/tree/HEAD"],
24
+ ["bitbucket.org", "src/HEAD"]
25
+ ]);
16
26
  const stripGitSuffix = (value) => value.endsWith(".git") ? value.slice(0, -4) : value;
17
27
  /**
18
28
  * The browsable `https://host/path` form of a repository reference, or none
@@ -53,6 +63,34 @@ const KNOWN_REPOSITORY_KEYS = /* @__PURE__ */ new Set([
53
63
  "directory"
54
64
  ]);
55
65
  const KNOWN_BUGS_KEYS = /* @__PURE__ */ new Set(["url", "email"]);
66
+ const sameRest = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
67
+ /** The keys of `wire` outside the documented set, which is what `rest` holds. */
68
+ const restOf = (wire, known) => {
69
+ const rest = {};
70
+ for (const [key, value] of Object.entries(wire)) if (!known.has(key)) rest[key] = value;
71
+ return rest;
72
+ };
73
+ /**
74
+ * Whether the shorthand string can still carry everything this value holds.
75
+ *
76
+ * @remarks
77
+ * A shorthand is only a `url` — it has no syntax for `type`, `directory` or an
78
+ * unknown key. So a repository decoded from a string that later GAINS one of
79
+ * those is no longer described by the wire it remembers, and replaying that
80
+ * wire drops the addition silently. This is the same stale-provenance class the
81
+ * object branch guards, reached through the one field the shorthand cannot
82
+ * express, and `Person.isShorthandExpressible` / `Funding.isStringExpressible`
83
+ * already gate their own string branches this way.
84
+ *
85
+ * `directory` is the live case: this package ships `Repository.directoryUrl`,
86
+ * so a consumer reading a bare-string `repository` and making it a monorepo
87
+ * member is the natural mutator.
88
+ */
89
+ const isStringExpressibleRepository = (repository) => repository.type === void 0 && repository.directory === void 0 && Object.keys(repository.rest ?? {}).length === 0;
90
+ /** Whether the bare URL string can still carry everything this `bugs` entry holds. */
91
+ const isStringExpressibleBugs = (bugs) => bugs.email === void 0 && Object.keys(bugs.rest ?? {}).length === 0;
92
+ const isFaithfulRepository = (wire, repository) => wire.url === repository.url && wire.type === repository.type && wire.directory === repository.directory && sameRest(restOf(wire, KNOWN_REPOSITORY_KEYS), repository.rest);
93
+ const isFaithfulBugs = (wire, bugs) => wire.url === bugs.url && wire.email === bugs.email && sameRest(restOf(wire, KNOWN_BUGS_KEYS), bugs.rest);
56
94
  /**
57
95
  * Where a package's source lives.
58
96
  *
@@ -92,6 +130,60 @@ var Repository = class Repository extends Schema.Class("Repository")({
92
130
  return Option.map(this.browseUrl, (url) => `${url}.git`);
93
131
  }
94
132
  /**
133
+ * The browsable URL of **this package** — {@link Repository.browseUrl}
134
+ * descended into `directory` when the package is a monorepo
135
+ * member.
136
+ *
137
+ * @remarks
138
+ * Prefer this over `browseUrl` whenever the question is "where does this
139
+ * package live". For a monorepo, `browseUrl` answers with the repository
140
+ * root, so every member of the repository reports the same location — which
141
+ * matters because that URL is exactly what a consumer (a docs site's
142
+ * structured data, say) uses to tell two packages apart.
143
+ *
144
+ * The three outcomes are deliberately distinct:
145
+ *
146
+ * - **No `directory`** — the package *is* the repository root, so this is
147
+ * `browseUrl`. A correct answer, not a missing one.
148
+ * - **`directory` on a host this model knows** (GitHub, GitLab, Bitbucket) —
149
+ * the descended URL.
150
+ * - **`directory` on any other host** — `Option.none()`. The path convention
151
+ * for browsing a subdirectory is per-forge and cannot be guessed, and
152
+ * fabricating one would produce a URL that resolves to nothing while
153
+ * looking authoritative.
154
+ *
155
+ * What to do with that `none` is a policy this getter deliberately leaves to
156
+ * the caller, because it depends on what is being filled in. Falling back to
157
+ * {@link Repository.browseUrl} is reasonable wherever a less precise answer
158
+ * beats no answer — the repository root is a *true* location for the package,
159
+ * merely one that does not distinguish it from its siblings. Omit the value
160
+ * instead wherever that lack of distinction is the whole point. What is never
161
+ * reasonable is inventing a subdirectory path for a host this model does not
162
+ * recognize, which is the case this `none` exists to prevent.
163
+ *
164
+ * A `directory` that escapes the repository (any `..` segment) is refused the
165
+ * same way. One that resolves to the root itself (`"."`, `"/"`) is the root.
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * // { url: "effected/kit", directory: "packages/spdx" }
170
+ * // => https://github.com/effected/kit/tree/HEAD/packages/spdx
171
+ * ```
172
+ */
173
+ get directoryUrl() {
174
+ const directory = this.directory;
175
+ if (directory === void 0) return this.browseUrl;
176
+ const segments = directory.split("/").map((segment) => segment.trim()).filter((segment) => segment !== "" && segment !== ".");
177
+ if (segments.some((segment) => segment === "..")) return Option.none();
178
+ if (segments.length === 0) return this.browseUrl;
179
+ return Option.flatMap(this.browseUrl, (url) => {
180
+ const host = /^https:\/\/([^/]+)/.exec(url)?.[1];
181
+ const path = host === void 0 ? void 0 : DIRECTORY_PATHS.get(host);
182
+ if (path === void 0) return Option.none();
183
+ return Option.some(`${url}/${path}/${segments.map((segment) => encodeURIComponent(segment)).join("/")}`);
184
+ });
185
+ }
186
+ /**
95
187
  * The `repository` field: the shorthand string or the object form, always
96
188
  * decoded to a {@link Repository}, and always re-encoded in the form it was
97
189
  * read from.
@@ -116,8 +208,8 @@ var Repository = class Repository extends Schema.Class("Repository")({
116
208
  },
117
209
  encode: (repository) => {
118
210
  const wire = repositoryWires.get(repository);
119
- if (typeof wire === "string" && wire === repository.url) return wire;
120
- if (wire !== void 0 && typeof wire !== "string") return wire;
211
+ if (typeof wire === "string" && wire === repository.url && isStringExpressibleRepository(repository)) return wire;
212
+ if (wire !== void 0 && typeof wire !== "string" && isFaithfulRepository(wire, repository)) return wire;
121
213
  return {
122
214
  ...repository.type !== void 0 && { type: repository.type },
123
215
  url: repository.url,
@@ -164,8 +256,8 @@ var Bugs = class Bugs extends Schema.Class("Bugs")({
164
256
  },
165
257
  encode: (bugs) => {
166
258
  const wire = bugsWires.get(bugs);
167
- if (typeof wire === "string" && wire === bugs.url && bugs.email === void 0) return wire;
168
- if (wire !== void 0 && typeof wire !== "string") return wire;
259
+ if (typeof wire === "string" && wire === bugs.url && isStringExpressibleBugs(bugs)) return wire;
260
+ if (wire !== void 0 && typeof wire !== "string" && isFaithfulBugs(wire, bugs)) return wire;
169
261
  return {
170
262
  ...bugs.url !== void 0 && { url: bugs.url },
171
263
  ...bugs.email !== void 0 && { email: bugs.email },
package/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { JsoncEdit, JsoncEdit as JsoncEdit$1, JsoncPath, JsoncPath as JsoncPath$
2
2
  import { CatalogResolver, DependencyKind, DependencyProtocol, DependencyProtocol as DependencyProtocol$1, DependencySpecifier, DependencySpecifierBrand, InvalidDependencySpecifierError, WorkspaceResolver, isValidDependencySpecifier } from "@effected/npm";
3
3
  import { InvalidVersionError, Range, SemVer } from "@effected/semver";
4
4
  import { Brand, Context, Effect, FileSystem, HashMap, Layer, Option, Path, Result, Schema } from "effect";
5
+ import { SpdxExpression } from "@effected/spdx";
5
6
  //#region src/Dependency.d.ts
6
7
  declare const Dependency_base: Schema.Class<Dependency, Schema.Struct<{
7
8
  /** The package name. */
@@ -224,6 +225,72 @@ interface EntryPointManifest {
224
225
  */
225
226
  declare const resolveEntryPoint: (manifest: EntryPointManifest, options?: ResolveEntryPointOptions) => Result.Result<string, UnresolvedEntryPointError>;
226
227
  //#endregion
228
+ //#region src/Funding.d.ts
229
+ declare const Funding_base: Schema.Class<Funding, Schema.Struct<{
230
+ /** The funding platform, when the object form carried one (`"github"`, …). */
231
+ readonly type: Schema.optionalKey<Schema.String>;
232
+ /** Where the money goes, exactly as the manifest wrote it. */
233
+ readonly url: Schema.String;
234
+ /** Keys outside the documented set, preserved so encoding does not drop them. */
235
+ readonly rest: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
236
+ }>, {}>;
237
+ /**
238
+ * Where to send money for a package: one funding entry.
239
+ *
240
+ * @remarks
241
+ * npm's `funding` field accepts a bare URL string, this object form, or an
242
+ * array of either. `url` is **required** — it is the only thing the field
243
+ * actually says — so an object without one fails to decode rather than
244
+ * producing a half-populated entry. `type` (`"individual"`, `"github"`, …) is
245
+ * caller data and is kept **verbatim**, never normalized.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * import { Funding } from "@effected/package-json";
250
+ * import { Effect, Schema } from "effect";
251
+ *
252
+ * const program = Effect.gen(function* () {
253
+ * // Always an array, whichever encoding the manifest used.
254
+ * const entries = yield* Schema.decodeUnknownEffect(Funding.FromField)("https://example.com/sponsor");
255
+ * console.log(entries[0]?.url); // "https://example.com/sponsor"
256
+ * });
257
+ * ```
258
+ *
259
+ * @public
260
+ */
261
+ declare class Funding extends Funding_base {
262
+ /**
263
+ * A single `funding` entry: the bare URL string or the object form, always
264
+ * decoded to a {@link Funding} and always re-encoded in the form it was read
265
+ * from.
266
+ *
267
+ * @remarks
268
+ * Provenance belongs to the instance, so an entry that is *rebuilt* rather
269
+ * than carried through has none and encodes in the canonical object form.
270
+ */
271
+ static readonly FromValue: Schema.Codec<Funding, string | {
272
+ readonly [k: string]: unknown;
273
+ }>;
274
+ /**
275
+ * The `funding` field: a lone entry or an array of them, **always** decoded
276
+ * to an array so a consumer never branches on arity.
277
+ *
278
+ * @remarks
279
+ * The normalization is one-directional. A field written bare re-encodes
280
+ * bare, not as a one-element array — the arity is remembered against the
281
+ * single entry that WAS the field, and the replay is guarded on that entry
282
+ * still being alone, so pushing a second entry into the decoded array in
283
+ * place upgrades the field to the array form instead of silently dropping
284
+ * the addition. An entry built by hand has no provenance, so an array of
285
+ * such entries encodes as an array.
286
+ */
287
+ static readonly FromField: Schema.Codec<ReadonlyArray<Funding>, string | {
288
+ readonly [k: string]: unknown;
289
+ } | ReadonlyArray<string | {
290
+ readonly [k: string]: unknown;
291
+ }>>;
292
+ }
293
+ //#endregion
227
294
  //#region src/License.d.ts
228
295
  declare const InvalidSpdxLicenseError_base: Schema.Class<InvalidSpdxLicenseError, Schema.TaggedStruct<"InvalidSpdxLicenseError", {
229
296
  /** The raw input string that failed validation. */
@@ -251,6 +318,17 @@ declare const isValidSpdx: (value: string) => boolean;
251
318
  * A valid SPDX license identifier, expression, `UNLICENSED`, or
252
319
  * `SEE LICENSE IN <file>`.
253
320
  *
321
+ * @remarks
322
+ * **A branded value here is not necessarily parseable as SPDX.** npm's
323
+ * `license` field admits two strings that the SPDX grammar does not —
324
+ * `UNLICENSED` and `SEE LICENSE IN <file>` — and this brand accepts both,
325
+ * because it models what a manifest may legally carry, not what SPDX defines.
326
+ * Feeding a branded value straight to `SpdxExpression.parse` therefore fails
327
+ * on exactly those two forms. Do not hand-screen for them — reach for
328
+ * {@link licenseExpressionOf}, which answers "what expression is this, if any"
329
+ * and yields `Option.none()` for a spelling that is not one. Reach for
330
+ * {@link isValidSpdx} when the question is instead "may a manifest carry this".
331
+ *
254
332
  * @public
255
333
  */
256
334
  declare const SpdxLicense: Schema.brand<Schema.String, "SpdxLicense">;
@@ -260,6 +338,40 @@ declare const SpdxLicense: Schema.brand<Schema.String, "SpdxLicense">;
260
338
  * @public
261
339
  */
262
340
  type SpdxLicense = string & Brand.Brand<"SpdxLicense">;
341
+ /**
342
+ * The parsed SPDX expression a manifest's `license` denotes, or `Option.none()`
343
+ * when it denotes no expression at all.
344
+ *
345
+ * @remarks
346
+ * This is the accessor to reach for whenever a branded `SpdxLicense` has
347
+ * to become an actual expression — a license URL, a badge, structured data, a
348
+ * policy check. It exists because the brand and the grammar disagree, and that
349
+ * disagreement is knowledge these two packages jointly own rather than
350
+ * something each consumer should rediscover.
351
+ *
352
+ * `UNLICENSED` and `SEE LICENSE IN <file>` are legal in a manifest and are not
353
+ * SPDX expressions, so they yield `Option.none()`. Everything else parses.
354
+ * A consumer screening for those two spellings by hand gets it wrong the day
355
+ * npm admits a third — this accessor is the mechanism that prose could not be,
356
+ * and it needs no change on that day, because "not an expression" is answered
357
+ * by the grammar rather than by a list of spellings kept in step with npm.
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * import { licenseExpressionOf } from "@effected/package-json";
362
+ * import { SpdxExpression } from "@effected/spdx";
363
+ *
364
+ * // "MIT" => Option.some(<LicenseNode MIT>)
365
+ * // "UNLICENSED" => Option.none()
366
+ * // "SEE LICENSE IN LICENSE.txt" => Option.none()
367
+ * ```
368
+ *
369
+ * @param license - a branded manifest license value
370
+ * @returns the parsed expression, or none for a spelling that is not one
371
+ *
372
+ * @public
373
+ */
374
+ declare const licenseExpressionOf: (license: SpdxLicense) => Option.Option<SpdxExpression>;
263
375
  //#endregion
264
376
  //#region src/PackageManager.d.ts
265
377
  declare const PackageManager_base: Schema.Class<PackageManager, Schema.Struct<{
@@ -505,6 +617,48 @@ declare class Repository extends Repository_base {
505
617
  get browseUrl(): Option.Option<string>;
506
618
  /** The canonical https clone URL, or none when it cannot be derived. */
507
619
  get gitUrl(): Option.Option<string>;
620
+ /**
621
+ * The browsable URL of **this package** — {@link Repository.browseUrl}
622
+ * descended into `directory` when the package is a monorepo
623
+ * member.
624
+ *
625
+ * @remarks
626
+ * Prefer this over `browseUrl` whenever the question is "where does this
627
+ * package live". For a monorepo, `browseUrl` answers with the repository
628
+ * root, so every member of the repository reports the same location — which
629
+ * matters because that URL is exactly what a consumer (a docs site's
630
+ * structured data, say) uses to tell two packages apart.
631
+ *
632
+ * The three outcomes are deliberately distinct:
633
+ *
634
+ * - **No `directory`** — the package *is* the repository root, so this is
635
+ * `browseUrl`. A correct answer, not a missing one.
636
+ * - **`directory` on a host this model knows** (GitHub, GitLab, Bitbucket) —
637
+ * the descended URL.
638
+ * - **`directory` on any other host** — `Option.none()`. The path convention
639
+ * for browsing a subdirectory is per-forge and cannot be guessed, and
640
+ * fabricating one would produce a URL that resolves to nothing while
641
+ * looking authoritative.
642
+ *
643
+ * What to do with that `none` is a policy this getter deliberately leaves to
644
+ * the caller, because it depends on what is being filled in. Falling back to
645
+ * {@link Repository.browseUrl} is reasonable wherever a less precise answer
646
+ * beats no answer — the repository root is a *true* location for the package,
647
+ * merely one that does not distinguish it from its siblings. Omit the value
648
+ * instead wherever that lack of distinction is the whole point. What is never
649
+ * reasonable is inventing a subdirectory path for a host this model does not
650
+ * recognize, which is the case this `none` exists to prevent.
651
+ *
652
+ * A `directory` that escapes the repository (any `..` segment) is refused the
653
+ * same way. One that resolves to the root itself (`"."`, `"/"`) is the root.
654
+ *
655
+ * @example
656
+ * ```ts
657
+ * // { url: "effected/kit", directory: "packages/spdx" }
658
+ * // => https://github.com/effected/kit/tree/HEAD/packages/spdx
659
+ * ```
660
+ */
661
+ get directoryUrl(): Option.Option<string>;
508
662
  /**
509
663
  * The `repository` field: the shorthand string or the object form, always
510
664
  * decoded to a {@link Repository}, and always re-encoded in the form it was
@@ -681,6 +835,11 @@ declare const Package_base: Schema.Class<Package, Schema.Struct<{
681
835
  readonly bugs: Schema.optionalKey<Schema.Codec<Bugs, string | {
682
836
  readonly [k: string]: unknown;
683
837
  }, never, never>>;
838
+ readonly funding: Schema.optionalKey<Schema.Codec<readonly Funding[], string | readonly (string | {
839
+ readonly [k: string]: unknown;
840
+ })[] | {
841
+ readonly [k: string]: unknown;
842
+ }, never, never>>;
684
843
  readonly homepage: Schema.optionalKey<Schema.String>;
685
844
  readonly dependencies: Schema.decodeTo<Schema.HashMap<Schema.String, Schema.String>, Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>, never, never>;
686
845
  readonly devDependencies: Schema.decodeTo<Schema.HashMap<Schema.String, Schema.String>, Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>, never, never>;
@@ -1078,6 +1237,163 @@ declare class PackageJsonFormat {
1078
1237
  static readonly modifyToString: (source: string, path: JsoncPath$1, value: unknown) => Effect.Effect<string, PackageJsonModifyError | PackageJsonSyntaxError, never>;
1079
1238
  }
1080
1239
  //#endregion
1240
+ //#region src/LenientManifest.d.ts
1241
+ /**
1242
+ * One degraded field from a lenient decode: the top-level `field` that did not
1243
+ * match its permissive shape, a human-readable description of the `expected`
1244
+ * shape, and the raw `value` found there (also preserved verbatim under
1245
+ * `LenientManifest.rest[field]`).
1246
+ *
1247
+ * A value, not an error — the decode still succeeds; issues exist so callers
1248
+ * can report what degraded.
1249
+ *
1250
+ * @public
1251
+ */
1252
+ interface LenientFieldIssue {
1253
+ /** The top-level field name that degraded, e.g. `"name"`. */
1254
+ readonly field: string;
1255
+ /** A human-readable description of the permissive shape the field required. */
1256
+ readonly expected: string;
1257
+ /** The raw value found on the wire, preserved for reporting. */
1258
+ readonly value: unknown;
1259
+ }
1260
+ declare const LenientManifest_base: Schema.Class<LenientManifest, Schema.Struct<{
1261
+ readonly name: Schema.optionalKey<Schema.String>;
1262
+ readonly version: Schema.optionalKey<Schema.String>;
1263
+ readonly description: Schema.optionalKey<Schema.String>;
1264
+ readonly private: Schema.optionalKey<Schema.Boolean>;
1265
+ readonly type: Schema.optionalKey<Schema.String>;
1266
+ readonly main: Schema.optionalKey<Schema.String>;
1267
+ readonly license: Schema.optionalKey<Schema.String>;
1268
+ readonly author: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.Unknown>]>>;
1269
+ readonly contributors: Schema.optionalKey<Schema.$Array<Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.Unknown>]>>>;
1270
+ readonly maintainers: Schema.optionalKey<Schema.$Array<Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.Unknown>]>>>;
1271
+ readonly keywords: Schema.optionalKey<Schema.$Array<Schema.String>>;
1272
+ readonly repository: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.Unknown>]>>;
1273
+ readonly bugs: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.Unknown>]>>;
1274
+ readonly funding: Schema.optionalKey<Schema.Union<readonly [Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.Unknown>]>, Schema.$Array<Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.Unknown>]>>]>>;
1275
+ readonly homepage: Schema.optionalKey<Schema.String>;
1276
+ readonly dependencies: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
1277
+ readonly devDependencies: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
1278
+ readonly peerDependencies: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
1279
+ readonly optionalDependencies: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
1280
+ readonly peerDependenciesMeta: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
1281
+ readonly scripts: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
1282
+ readonly bin: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.String>]>>;
1283
+ readonly engines: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
1284
+ readonly exports: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.$Record<Schema.String, Schema.Unknown>]>>;
1285
+ readonly publishConfig: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
1286
+ readonly packageManager: Schema.optionalKey<Schema.String>;
1287
+ readonly devEngines: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
1288
+ /**
1289
+ * Unknown top-level keys, plus every degraded known field's raw value,
1290
+ * verbatim. Always present after a lenient decode (possibly empty).
1291
+ */
1292
+ readonly rest: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Unknown>>;
1293
+ /** The degradations collected by the decode — empty when nothing degraded. */
1294
+ readonly issues: Schema.$Array<Schema.Struct<{
1295
+ readonly field: Schema.String;
1296
+ readonly expected: Schema.String;
1297
+ readonly value: Schema.Unknown;
1298
+ }>>;
1299
+ }>, {}>;
1300
+ /**
1301
+ * The shape-lenient view of a package.json document, for discovery and
1302
+ * sniffing — probing a fetched tarball's manifest, walking a `node_modules`
1303
+ * tree, listing candidate packages — where the document is other people's
1304
+ * data and one malformed field must not fail the read.
1305
+ *
1306
+ * @remarks
1307
+ * **This is the discovery tier, not a validation bypass.** Every field shares
1308
+ * its name with the strict `Package` model, but is typed as its plain permissive JSON
1309
+ * shape: `name` and `version` are any string (a legacy uppercase name or a
1310
+ * non-semver `"1.0"` is recovered, not rejected), `license` is any string (no
1311
+ * SPDX check), the dependency maps and `scripts` are plain string→string
1312
+ * records rather than `HashMap`s. A present field that is not even that shape
1313
+ * **degrades to absence** rather than failing the document: the raw value is
1314
+ * preserved verbatim in `rest` and the degradation is reported on `issues`,
1315
+ * so callers can surface what was ignored. Degradation granularity is the
1316
+ * top-level field — one junk entry degrades its whole map, with the raw map
1317
+ * still in `rest`.
1318
+ *
1319
+ * Leniency is per-field, never per-syntax: text that is not valid JSON fails
1320
+ * {@link LenientManifest.parseResult} as a typed
1321
+ * {@link PackageJsonSyntaxError}, and a value that is not a JSON object fails
1322
+ * {@link LenientManifest.decodeResult} as a typed {@link PackageDecodeError}.
1323
+ *
1324
+ * An empty `issues` array does **not** mean the strict tiers would accept the
1325
+ * document — the permissive shapes check JSON shape, not npm semantics. The
1326
+ * upgrade path is to decode the *original* input through
1327
+ * `PackageManifest.decode` (presence-lenient, shape-strict) or
1328
+ * `Package.decode` (strict, publishable) when validation is actually
1329
+ * wanted. This class deliberately carries no mutation statics and no write
1330
+ * path; editing belongs to the strict tiers and to
1331
+ * `PackageJsonFormat.modifyToString` / `PackageJsonFile.modify`.
1332
+ *
1333
+ * @example
1334
+ * ```ts
1335
+ * import { LenientManifest } from "@effected/package-json";
1336
+ * import { Effect } from "effect";
1337
+ *
1338
+ * const program = Effect.gen(function* () {
1339
+ * const sniffed = yield* LenientManifest.decode({ name: "JSONStream", version: "1.0", license: 42 });
1340
+ * console.log(sniffed.name, sniffed.version); // "JSONStream" "1.0"
1341
+ * console.log(sniffed.issues); // [{ field: "license", expected: "a string", value: 42 }]
1342
+ * console.log(sniffed.rest?.license); // 42 — degraded, preserved verbatim
1343
+ * });
1344
+ * ```
1345
+ *
1346
+ * @public
1347
+ */
1348
+ declare class LenientManifest extends LenientManifest_base {
1349
+ /**
1350
+ * Decode an unknown JSON value leniently, degrading malformed fields instead
1351
+ * of failing the document. The sync primitive backing
1352
+ * {@link LenientManifest.decode}.
1353
+ *
1354
+ * @param input - the parsed package.json JSON value (e.g. from `JSON.parse`)
1355
+ * @returns the lenient manifest, or a {@link PackageDecodeError} when
1356
+ * `input` is not a JSON object at all (`null`, an array or a scalar) — the
1357
+ * one failure leniency does not cover
1358
+ */
1359
+ static decodeResult(input: unknown): Result.Result<LenientManifest, PackageDecodeError>;
1360
+ /**
1361
+ * Decode an unknown JSON value leniently, degrading malformed fields instead
1362
+ * of failing the document. The `Effect` form of
1363
+ * {@link LenientManifest.decodeResult}, adding the tracing span.
1364
+ *
1365
+ * @param input - the parsed package.json JSON value (e.g. from `JSON.parse`)
1366
+ * @returns an Effect resolving to the decoded {@link LenientManifest}
1367
+ * @throws (typed) `PackageDecodeError` when `input` is not a JSON object
1368
+ */
1369
+ static readonly decode: (input: unknown) => Effect.Effect<LenientManifest, PackageDecodeError, never>;
1370
+ /**
1371
+ * Parse package.json text and decode it leniently. The sync primitive
1372
+ * backing {@link LenientManifest.parse}.
1373
+ *
1374
+ * @param text - the package.json source text
1375
+ * @returns the lenient manifest, or a {@link PackageJsonSyntaxError} when
1376
+ * the text is not valid JSON (`"invalid-json"`) or parses to something
1377
+ * other than a JSON object (`"not-an-object"`) — leniency is per-field,
1378
+ * never per-syntax
1379
+ */
1380
+ static parseResult(text: string): Result.Result<LenientManifest, PackageJsonSyntaxError>;
1381
+ /**
1382
+ * Parse package.json text and decode it leniently. The `Effect` form of
1383
+ * {@link LenientManifest.parseResult}, adding the tracing span.
1384
+ *
1385
+ * @param text - the package.json source text
1386
+ * @returns an Effect resolving to the decoded {@link LenientManifest}
1387
+ * @throws (typed) `PackageJsonSyntaxError` when the text is not valid JSON
1388
+ * or is not a JSON object
1389
+ */
1390
+ static readonly parse: (text: string) => Effect.Effect<LenientManifest, PackageJsonSyntaxError, never>;
1391
+ /** Whether the manifest is marked private. */
1392
+ get isPrivate(): boolean;
1393
+ /** Whether the manifest declares ESM (`"type": "module"`, exact comparison). */
1394
+ get isESM(): boolean;
1395
+ }
1396
+ //#endregion
1081
1397
  //#region src/PackageManagerRange.d.ts
1082
1398
  declare const PackageManagerRange_base: Schema.Class<PackageManagerRange, Schema.Struct<{
1083
1399
  /** The package-manager name (e.g. `pnpm`). Any lowercase name — the same latitude as {@link PackageManager}, for the same evidence. */
@@ -1187,6 +1503,11 @@ declare const PackageManifest_base: Schema.Class<PackageManifest, Schema.Struct<
1187
1503
  readonly bugs: Schema.optionalKey<Schema.Codec<Bugs, string | {
1188
1504
  readonly [k: string]: unknown;
1189
1505
  }, never, never>>;
1506
+ readonly funding: Schema.optionalKey<Schema.Codec<readonly Funding[], string | readonly (string | {
1507
+ readonly [k: string]: unknown;
1508
+ })[] | {
1509
+ readonly [k: string]: unknown;
1510
+ }, never, never>>;
1190
1511
  readonly homepage: Schema.optionalKey<Schema.String>;
1191
1512
  readonly dependencies: Schema.decodeTo<Schema.HashMap<Schema.String, Schema.String>, Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>, never, never>;
1192
1513
  readonly devDependencies: Schema.decodeTo<Schema.HashMap<Schema.String, Schema.String>, Schema.withDecodingDefaultKey<Schema.$Record<Schema.String, Schema.String>, never>, never, never>;
@@ -1233,10 +1554,12 @@ declare const PackageManifest_base: Schema.Class<PackageManifest, Schema.Struct<
1233
1554
  * typed), a present `name` must still satisfy the npm grammar, a present
1234
1555
  * `packageManager` must still parse — though here the version position may be
1235
1556
  * a semver range (`pnpm@^11.20.0`), decoded as {@link PackageManagerRange}.
1236
- * For total tolerance of malformed fields, use the decode-free
1237
- * {@link PackageJsonFormat} text path (or `@effected/npm`'s shape-blind
1238
- * `Manifest`)silently carrying a value the type claims to have validated
1239
- * would be a lie, and silently dropping it would break round-trip fidelity.
1557
+ * For tolerance of malformed fields, use the shape-lenient `LenientManifest`
1558
+ * discovery tier which degrades them to absence, preserved in `rest` and
1559
+ * reported on `issues` — or the decode-free {@link PackageJsonFormat} text
1560
+ * path (or `@effected/npm`'s shape-blind `Manifest`); here, silently carrying
1561
+ * a value the type claims to have validated would be a lie, and silently
1562
+ * dropping it would break round-trip fidelity.
1240
1563
  *
1241
1564
  * The model is deliberately lean — fields, the `rest` catch-all wire codec,
1242
1565
  * {@link PackageManifest.decode} and {@link PackageManifest.toJsonString} —
@@ -1542,5 +1865,5 @@ declare class PackageValidator extends PackageValidator_base {
1542
1865
  }): Layer.Layer<PackageValidator>;
1543
1866
  }
1544
1867
  //#endregion
1545
- export { BinField, Bugs, Dependency, type DependencyKind, DependencyMapField, type DependencyProtocol, DependencySpecifier, type DependencySpecifierBrand, DevEngine, DevEngineOrArray, type DevEngines, DevEnginesSchema, type EntryPointManifest, ExportsField, InvalidDependencySpecifierError, InvalidPackageNameError, InvalidSpdxLicenseError, JsoncEdit, type JsoncPath, Package, PackageDecodeError, type PackageFieldEdit, type PackageFormatOptions, type PackageFormatTextOptions, type PackageIndent, PackageJsonFile, type PackageJsonFileShape, PackageJsonFormat, PackageJsonModifyError, PackageJsonNotFoundError, PackageJsonParseError, PackageJsonReadError, PackageJsonSyntaxError, PackageJsonWriteError, PackageManager, PackageManagerRange, PackageManifest, PackageName, type PackagePatch, PackageValidationError, PackageValidator, PeerDependenciesMetaField, Person, PublishConfigField, Repository, RepositoryField, type ResolveEntryPointOptions, type RuleFailure, ScopedPackageName, SpdxLicense, StringMapField, type UnresolvedDependency, UnresolvedEntryPointError, UnscopedPackageName, type ValidationRule, defaultRules, isUnresolvedDependency, isValidDependencySpecifier, isValidSpdx, noLocalDepsRule, noUnresolvedDepsRule, resolveEntryPoint };
1868
+ export { BinField, Bugs, Dependency, type DependencyKind, DependencyMapField, type DependencyProtocol, DependencySpecifier, type DependencySpecifierBrand, DevEngine, DevEngineOrArray, type DevEngines, DevEnginesSchema, type EntryPointManifest, ExportsField, Funding, InvalidDependencySpecifierError, InvalidPackageNameError, InvalidSpdxLicenseError, JsoncEdit, type JsoncPath, type LenientFieldIssue, LenientManifest, Package, PackageDecodeError, type PackageFieldEdit, type PackageFormatOptions, type PackageFormatTextOptions, type PackageIndent, PackageJsonFile, type PackageJsonFileShape, PackageJsonFormat, PackageJsonModifyError, PackageJsonNotFoundError, PackageJsonParseError, PackageJsonReadError, PackageJsonSyntaxError, PackageJsonWriteError, PackageManager, PackageManagerRange, PackageManifest, PackageName, type PackagePatch, PackageValidationError, PackageValidator, PeerDependenciesMetaField, Person, PublishConfigField, Repository, RepositoryField, type ResolveEntryPointOptions, type RuleFailure, ScopedPackageName, SpdxLicense, StringMapField, type UnresolvedDependency, UnresolvedEntryPointError, UnscopedPackageName, type ValidationRule, defaultRules, isUnresolvedDependency, isValidDependencySpecifier, isValidSpdx, licenseExpressionOf, noLocalDepsRule, noUnresolvedDepsRule, resolveEntryPoint };
1546
1869
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { Dependency, isUnresolvedDependency } from "./Dependency.js";
2
2
  import { DevEngine, DevEngineOrArray, DevEnginesSchema } from "./DevEngines.js";
3
3
  import { UnresolvedEntryPointError, resolveEntryPoint } from "./EntryPoint.js";
4
- import { InvalidSpdxLicenseError, SpdxLicense, isValidSpdx } from "./License.js";
4
+ import { Funding } from "./Funding.js";
5
+ import { InvalidSpdxLicenseError, SpdxLicense, isValidSpdx, licenseExpressionOf } from "./License.js";
5
6
  import { PackageManager } from "./PackageManager.js";
6
7
  import { InvalidPackageNameError, PackageName, ScopedPackageName, UnscopedPackageName } from "./PackageName.js";
7
8
  import { Person } from "./Person.js";
8
9
  import { Bugs, Repository } from "./Repository.js";
9
10
  import { BinField, DependencyMapField, ExportsField, Package, PackageDecodeError, PeerDependenciesMetaField, PublishConfigField, RepositoryField, StringMapField } from "./Package.js";
10
11
  import { PackageJsonFormat, PackageJsonModifyError, PackageJsonSyntaxError } from "./PackageJsonFormat.js";
12
+ import { LenientManifest } from "./LenientManifest.js";
11
13
  import { PackageManagerRange } from "./PackageManagerRange.js";
12
14
  import { PackageManifest } from "./PackageManifest.js";
13
15
  import { PackageJsonFile, PackageJsonNotFoundError, PackageJsonParseError, PackageJsonReadError, PackageJsonWriteError } from "./PackageJsonFile.js";
@@ -15,4 +17,4 @@ import { PackageValidationError, PackageValidator, defaultRules, noLocalDepsRule
15
17
  import { JsoncEdit } from "@effected/jsonc";
16
18
  import { DependencySpecifier, InvalidDependencySpecifierError, isValidDependencySpecifier } from "@effected/npm";
17
19
 
18
- export { BinField, Bugs, Dependency, DependencyMapField, DependencySpecifier, DevEngine, DevEngineOrArray, DevEnginesSchema, ExportsField, InvalidDependencySpecifierError, InvalidPackageNameError, InvalidSpdxLicenseError, JsoncEdit, Package, PackageDecodeError, PackageJsonFile, PackageJsonFormat, PackageJsonModifyError, PackageJsonNotFoundError, PackageJsonParseError, PackageJsonReadError, PackageJsonSyntaxError, PackageJsonWriteError, PackageManager, PackageManagerRange, PackageManifest, PackageName, PackageValidationError, PackageValidator, PeerDependenciesMetaField, Person, PublishConfigField, Repository, RepositoryField, ScopedPackageName, SpdxLicense, StringMapField, UnresolvedEntryPointError, UnscopedPackageName, defaultRules, isUnresolvedDependency, isValidDependencySpecifier, isValidSpdx, noLocalDepsRule, noUnresolvedDepsRule, resolveEntryPoint };
20
+ export { BinField, Bugs, Dependency, DependencyMapField, DependencySpecifier, DevEngine, DevEngineOrArray, DevEnginesSchema, ExportsField, Funding, InvalidDependencySpecifierError, InvalidPackageNameError, InvalidSpdxLicenseError, JsoncEdit, LenientManifest, Package, PackageDecodeError, PackageJsonFile, PackageJsonFormat, PackageJsonModifyError, PackageJsonNotFoundError, PackageJsonParseError, PackageJsonReadError, PackageJsonSyntaxError, PackageJsonWriteError, PackageManager, PackageManagerRange, PackageManifest, PackageName, PackageValidationError, PackageValidator, PeerDependenciesMetaField, Person, PublishConfigField, Repository, RepositoryField, ScopedPackageName, SpdxLicense, StringMapField, UnresolvedEntryPointError, UnscopedPackageName, defaultRules, isUnresolvedDependency, isValidDependencySpecifier, isValidSpdx, licenseExpressionOf, noLocalDepsRule, noUnresolvedDepsRule, resolveEntryPoint };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/package-json",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "description": "package.json parsing, editing, validation and file IO as Effect schemas.",
6
6
  "keywords": [
@@ -38,10 +38,10 @@
38
38
  "./package.json": "./package.json"
39
39
  },
40
40
  "dependencies": {
41
- "@effected/jsonc": "^0.7.0",
42
- "@effected/npm": "^0.12.0",
41
+ "@effected/jsonc": "^0.8.0",
42
+ "@effected/npm": "^0.12.1",
43
43
  "@effected/semver": "^0.5.0",
44
- "@effected/spdx": "^0.4.0"
44
+ "@effected/spdx": "^0.5.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "effect": "4.0.0-rc.109"