@jarenjs/emit 0.72.0 → 0.72.2

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/README.md CHANGED
@@ -19,7 +19,7 @@ emitTypeScript({
19
19
  role: { enum: ['admin', 'user'] },
20
20
  },
21
21
  required: ['id'],
22
- }, { name: 'User' });
22
+ }, { name: 'User', banner: false });
23
23
  ```
24
24
 
25
25
  ```typescript
@@ -32,6 +32,7 @@ export interface User {
32
32
  */
33
33
  id: string;
34
34
  role?: "admin" | "user";
35
+ [key: string]: unknown;
35
36
  }
36
37
  ```
37
38
 
@@ -80,10 +81,10 @@ or a type so wide it certifies anything.
80
81
 
81
82
  Because Jaren owns **both sides**, that can be tested rather than trusted.
82
83
  The suite in `test/emit/agreement.test.js` takes a corpus of schemas plus
83
- instances, generates the declarations, writes a probe file that assigns every
84
- instance to its generated type, and runs `tsc` over it. Then it runs the
85
- compiled validator over the same instances and requires three relationships to
86
- hold:
84
+ instances, checks the generated declaration fixture, and runs the compiled
85
+ validator over those same instances. The companion `npm run test:types` gate
86
+ runs `tsc` over assignments to that fixture. Together the gates require three
87
+ relationships to hold:
87
88
 
88
89
  | Instance | Validator | Generated type | What a failure would mean |
89
90
  | --- | --- | --- | --- |
@@ -106,7 +107,10 @@ dropped constraint into the generated file:
106
107
  The type says `string`. The comment says the schema also demands a minimum
107
108
  length that the type does not enforce. A reader learns both. **Widening
108
109
  silently would be a lie of omission**, and it is the single most common way a
109
- generated type misleads the person reading it.
110
+ generated type misleads the person reading it. Anonymous constrained nodes gain
111
+ stable named declarations so their constraints have a documentation location;
112
+ objects with documented members are named as well. Markdown emits those member
113
+ notes after the declaration's table.
110
114
 
111
115
  The rig is checked against itself, too: deliberately breaking the generator so
112
116
  it emits `unknown` everywhere makes the "not wider than the schema" assertion
@@ -146,7 +150,7 @@ handler signature takes `ConfigInput`, the rest of the program handles
146
150
  `Config`, and the normalizer is the transition between them.
147
151
 
148
152
  **A twin appears only where the type actually differs.** The generator works
149
- that out bottom-up, so a schema with one defaulted field does not double every
153
+ that out across the reference graph, including cycles, so a schema with one defaulted field does not double every
150
154
  declaration; everything unaffected keeps a single shared name on both sides.
151
155
 
152
156
  **Only two normalizations produce a difference.** `useDefaults` moves a member
@@ -212,6 +216,10 @@ jaren-emit --schema <file|dir> --out <dir> [options]
212
216
  --check write nothing; exit 1 if any output is out of date
213
217
  ```
214
218
 
219
+ Options requiring a value reject a missing value before writing. Input and
220
+ filesystem errors are concise diagnostics with exit status 2; output drift under
221
+ `--check` uses status 1.
222
+
215
223
  `--check` is the CI guard: it fails the build when a schema changed and the
216
224
  generated types did not, which is the failure mode that makes generated code
217
225
  untrustworthy in the first place.
@@ -68,6 +68,10 @@ export type EmitMember = {
68
68
  default?: any;
69
69
  constraints: EmitConstraint[];
70
70
  doc: string[];
71
+ /**
72
+ * - Selected extension annotations carried from the property schema
73
+ */
74
+ extensions?: Record<string, unknown>;
71
75
  };
72
76
  export type EmitDeclaration = {
73
77
  kind: 'declaration';
@@ -74,7 +74,9 @@ generated artifact that churns between runs cannot be reviewed in a diff.
74
74
  `name` is the property name **verbatim** — it is data, not an identifier, and
75
75
  an emitter quotes it if its target language requires that. `default` is
76
76
  present only when the source schema declares one. Members appear in the order
77
- the schema declared them.
77
+ the schema declared them. Names present only in `required` are appended in that
78
+ array's order with type `unknown`; the `properties` map is not required to
79
+ establish member presence.
78
80
 
79
81
  ### 4.1 The extension seam
80
82
 
@@ -194,7 +196,7 @@ boundary can name them separately.
194
196
  its counterpart in `variantOf`.
195
197
 
196
198
  **A twin is emitted only when the type actually differs.** A producer computes
197
- that bottom-up — a type differs if anything it contains differs — so a schema
199
+ that across reference cycles — a type differs if anything it contains differs — so a schema
198
200
  with one defaulted field does not double every declaration in the document.
199
201
  Everything unaffected is referenced by its single shared name from both sides.
200
202
 
@@ -229,6 +231,17 @@ Three consequences worth stating because each was once wrong:
229
231
  - coercion widens only nodes with a **single string-valued `type`**, because
230
232
  that is the only place `coerceToType` runs.
231
233
 
234
+ Anonymous nodes carrying unrepresentable constraints are promoted to stable,
235
+ named declarations so their documentation survives rendering. An anonymous
236
+ object with documented members is named for the same reason. This can add
237
+ helper declarations without changing the structural type. The Markdown emitter
238
+ prints member documentation after each member table.
239
+
240
+ `nullable: true` extends an explicitly declared type with `null`, matching the
241
+ validator's extension. Literal `const`/`enum` values still satisfy the declared
242
+ type and compose with sibling applicators; an incompatible literal cannot erase
243
+ a type constraint.
244
+
232
245
  ## 8. Determinism
233
246
 
234
247
  Two compilations of the same input MUST produce byte-identical models.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/emit",
3
3
  "private": false,
4
- "version": "0.72.0",
4
+ "version": "0.72.2",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -68,8 +68,8 @@
68
68
  "prepack": "npm run build:types"
69
69
  },
70
70
  "dependencies": {
71
- "@jarenjs/core": "^0.72.0",
72
- "@jarenjs/json": "^0.72.0",
73
- "@jarenjs/validate": "^0.72.0"
71
+ "@jarenjs/core": "^0.72.2",
72
+ "@jarenjs/json": "^0.72.2",
73
+ "@jarenjs/validate": "^0.72.2"
74
74
  }
75
75
  }
@@ -64,6 +64,7 @@
64
64
  "type": { "$ref": "#/$defs/type" },
65
65
  "required": { "type": "boolean" },
66
66
  "default": { "description": "The schema default, when it declares one." },
67
+ "extensions": { "type": "object", "description": "Selected property extension annotations, carried verbatim." },
67
68
  "constraints": { "$ref": "#/$defs/constraints" },
68
69
  "doc": { "$ref": "#/$defs/doc" }
69
70
  }
package/src/cli.js CHANGED
@@ -51,16 +51,24 @@ function parseArgs(argv) {
51
51
  defaults: false, coerce: false, suffix: 'Input',
52
52
  };
53
53
  for (let i = 2; i < argv.length; i++) {
54
+ const value = () => {
55
+ const option = argv[i];
56
+ const next = argv[i + 1];
57
+ if (next === undefined || next.startsWith('-'))
58
+ throw new Error(`${option} requires a value`);
59
+ i++;
60
+ return next;
61
+ };
54
62
  switch (argv[i]) {
55
- case '--schema': options.schema = argv[++i]; break;
56
- case '--out': options.out = argv[++i]; break;
57
- case '--target': options.target = argv[++i]; break;
58
- case '--name': options.name = argv[++i]; break;
59
- case '--bundle': options.bundle = argv[++i]; break;
63
+ case '--schema': options.schema = value(); break;
64
+ case '--out': options.out = value(); break;
65
+ case '--target': options.target = value(); break;
66
+ case '--name': options.name = value(); break;
67
+ case '--bundle': options.bundle = value(); break;
60
68
  case '--check': options.check = true; break;
61
69
  case '--defaults': options.defaults = true; break;
62
70
  case '--coerce': options.coerce = true; break;
63
- case '--suffix': options.suffix = argv[++i]; break;
71
+ case '--suffix': options.suffix = value(); break;
64
72
  case '--help': case '-h': options.help = true; break;
65
73
  default:
66
74
  throw new Error(`unknown option: ${argv[i]}`);
@@ -203,6 +211,12 @@ function main() {
203
211
  }
204
212
  }
205
213
 
206
- main();
214
+ try {
215
+ main();
216
+ }
217
+ catch (error) {
218
+ console.error(error instanceof Error ? error.message : String(error));
219
+ process.exitCode = 2;
220
+ }
207
221
 
208
222
  //#endregion
package/src/markdown.js CHANGED
@@ -43,6 +43,7 @@ export const MARKDOWN_STYLESHEET = {
43
43
  '| Member | Type | Required |\n| --- | --- | --- |\n',
44
44
  [{ $apply: ['$.members[*]', 'row'] }],
45
45
  '\n',
46
+ [{ $apply: ['$.members[?@.doc[0]]', 'memberdoc'] }],
46
47
  ],
47
48
  },
48
49
  {
@@ -55,6 +56,13 @@ export const MARKDOWN_STYLESHEET = {
55
56
  [{ $apply: ['$.required', 'yesno'] }], ' |\n'],
56
57
  },
57
58
  { mode: 'yesno', body: [{ $if: ['$', 'yes', 'no'] }] },
59
+ {
60
+ mode: 'memberdoc',
61
+ body: [
62
+ '**`', { $raw: '$.name' }, '`**\n\n',
63
+ [{ $apply: ['$.doc[*]', 'docline'] }],
64
+ ],
65
+ },
58
66
 
59
67
  // --- the same type vocabulary, printed as prose ---------------------
60
68
  { match: isKind('primitive'), mode: 'type', body: [{ $raw: '$.primitive' }] },
package/src/model.js CHANGED
@@ -28,7 +28,7 @@
28
28
  // a private intermediate: a third-party stylesheet targets it, and `emit`'s
29
29
  // own TypeScript and Markdown emitters have no privileged access.
30
30
 
31
- import { isJsonObject } from '@jarenjs/core/object';
31
+ import { isJsonObject, setObjectMember } from '@jarenjs/core/object';
32
32
  import {
33
33
  NUMERIC_CONSTRAINTS, STRING_CONSTRAINTS,
34
34
  ARRAY_CONSTRAINTS, OBJECT_CONSTRAINTS,
@@ -79,6 +79,7 @@ export const EMIT_MODEL_VERSION = '0.1';
79
79
  * @property {any} [default] - The schema default, when it declares one
80
80
  * @property {EmitConstraint[]} constraints
81
81
  * @property {string[]} doc
82
+ * @property {Record<string, unknown>} [extensions] - Selected extension annotations carried from the property schema
82
83
  */
83
84
 
84
85
  /**
@@ -337,7 +338,7 @@ function unionOf(types) {
337
338
  * analysis answering differently from the runtime is precisely the defect the
338
339
  * variants exist to rule out.
339
340
  *
340
- * Computed bottom-up and memoized, because a type differs if anything it
341
+ * Follows reference reachability, because a type differs if anything it
341
342
  * contains differs. A node reached while it is still being analyzed is a
342
343
  * cycle, and a cycle alone introduces no difference, so it answers `false`.
343
344
  * @param {any} node - The schema node
@@ -411,7 +412,10 @@ function normalizationChangesType(node, ctx) {
411
412
  }
412
413
 
413
414
  ctx.analyzing.delete(node);
414
- memo.set(node, differs);
415
+ // A false answer reached through a backedge may still depend on a node
416
+ // whose coercible member has not been visited. Only a completed outer
417
+ // traversal proves false; a discovered difference is final immediately.
418
+ if (differs || ctx.analyzing.size === 0) memo.set(node, differs);
415
419
  return differs;
416
420
  }
417
421
 
@@ -509,9 +513,10 @@ function reserveName(ctx, preferred) {
509
513
  * @param {any} node - The schema node
510
514
  * @param {object} ctx - The compile context
511
515
  * @param {string} hint - A name to use if this node has to be hoisted
516
+ * @param {boolean} [documented=false] - The owning member already carries this node's own documentation
512
517
  * @returns {object} A type ref
513
518
  */
514
- function typeOf(node, ctx, hint) {
519
+ function typeOf(node, ctx, hint, documented = false) {
515
520
  if (node === true || node === undefined) return T.unknown();
516
521
  if (node === false) return T.never();
517
522
  if (!isJsonObject(node)) return T.unknown();
@@ -529,6 +534,15 @@ function typeOf(node, ctx, hint) {
529
534
  return T.ref(name);
530
535
  }
531
536
 
537
+ // Anonymous constraints need a declaration's documentation slot. Object
538
+ // member documentation also needs the declaration printer: an inline
539
+ // object type has no member comment surface in either bundled emitter.
540
+ const documentedMembers = isJsonObject(node.properties)
541
+ && Object.values(node.properties).some((member) => isJsonObject(member)
542
+ && (droppedConstraints(member).length > 0
543
+ || (typeof member.description === 'string' && member.description.length > 0)));
544
+ if ((!documented && droppedConstraints(node).length > 0) || documentedMembers)
545
+ return declare(node, ctx, hint);
532
546
  return shapeOf(node, ctx, hint);
533
547
  }
534
548
 
@@ -671,6 +685,24 @@ function coercionCanProduce(source, type, value) {
671
685
  }
672
686
  }
673
687
 
688
+ /** Whether a literal satisfies the explicitly declared JSON type. */
689
+ function literalMatchesType(value, node) {
690
+ if (node.type === undefined) return true;
691
+ if (value === null && node.nullable === true) return true;
692
+ const types = Array.isArray(node.type) ? node.type : [node.type];
693
+ return types.some((type) => {
694
+ switch (type) {
695
+ case 'null': return value === null;
696
+ case 'object': return isJsonObject(value);
697
+ case 'array': return Array.isArray(value);
698
+ case 'integer': return typeof value === 'number' && Number.isInteger(value);
699
+ case 'number': return typeof value === 'number' && Number.isFinite(value);
700
+ case 'string': case 'boolean': return typeof value === type;
701
+ default: return true;
702
+ }
703
+ });
704
+ }
705
+
674
706
  /** The structural shape of a schema node. */
675
707
  function shapeOf(node, ctx, hint) {
676
708
  const parts = [];
@@ -689,41 +721,38 @@ function shapeOf(node, ctx, hint) {
689
721
  parts.push(declare(target.node, ctx, target.name ?? hint));
690
722
  }
691
723
 
692
- // const and enum are the most precise things a schema can say. On the
693
- // accepted side the literal set still admits what the normalizer coerces
694
- // INTO a member `"2"` for an integer enum — so it widens by the source
695
- // primitives that can actually reach one.
696
- if (node.const !== undefined || Array.isArray(node.enum)) {
697
- const values = node.const !== undefined ? [node.const] : node.enum;
698
- const literals = values.map((v) => T.literal(v));
724
+ // Literals still satisfy the node's type and every sibling applicator.
725
+ // Filter the type here instead of emitting redundant `"a" & string` arms.
726
+ const hasLiterals = node.const !== undefined || Array.isArray(node.enum);
727
+ let containerLiteral = false;
728
+ if (hasLiterals) {
729
+ const values = (node.const !== undefined ? [node.const] : node.enum)
730
+ .filter((value) => literalMatchesType(value, node));
731
+ const literals = values.map((value) => T.literal(value));
732
+ containerLiteral = values.some((value) => isJsonObject(value) || Array.isArray(value));
699
733
  for (const source of coercionSources(node, values, ctx))
700
734
  literals.push(T.primitive(PRIMITIVES[source]));
701
735
  parts.push(unionOf(literals));
702
736
  }
703
- else {
704
- // allOf is intersection.
705
- if (Array.isArray(node.allOf) && node.allOf.length > 0) {
706
- const branches = node.allOf.map((b, i) => typeOf(b, ctx, `${hint}Part${i + 1}`));
707
- const usable = branches.filter((t) => t.kind !== 'unknown');
708
- if (usable.length === 1) parts.push(usable[0]);
709
- else if (usable.length > 1) parts.push(T.intersection(usable));
710
- }
711
737
 
712
- // anyOf and oneOf are both unions at the type level. oneOf's exclusivity
713
- // is a validation property with no type-level equivalent, so it widens to
714
- // the same union rather than being faked. When variants are being
715
- // derived, branches compile in the PLAIN universe: the runtime
716
- // normalizer does not descend them (see createPlainContext).
717
- for (const key of ['anyOf', 'oneOf']) {
718
- if (Array.isArray(node[key]) && node[key].length > 0) {
719
- const branchCtx = ctx.normalize === null
720
- ? ctx
721
- : (ctx.plain ??= createPlainContext(ctx));
722
- parts.push(unionOf(node[key].map((b, i) =>
723
- typeOf(b, branchCtx, `${hint}${toIdentifier(key)}${i + 1}`))));
724
- }
738
+ if (Array.isArray(node.allOf) && node.allOf.length > 0) {
739
+ const branches = node.allOf.map((branch, i) => typeOf(branch, ctx, `${hint}Part${i + 1}`));
740
+ const usable = branches.filter((type) => type.kind !== 'unknown');
741
+ if (usable.length === 1) parts.push(usable[0]);
742
+ else if (usable.length > 1) parts.push(T.intersection(usable));
743
+ }
744
+
745
+ // The normalizer does not descend union branches, so both variants use
746
+ // the plain schema reading of those branches.
747
+ for (const key of ['anyOf', 'oneOf']) {
748
+ if (Array.isArray(node[key]) && node[key].length > 0) {
749
+ const branchCtx = ctx.normalize === null ? ctx : (ctx.plain ??= createPlainContext(ctx));
750
+ parts.push(unionOf(node[key].map((branch, i) =>
751
+ typeOf(branch, branchCtx, `${hint}${toIdentifier(key)}${i + 1}`))));
725
752
  }
753
+ }
726
754
 
755
+ if (!hasLiterals || containerLiteral) {
727
756
  const own = ownShape(node, ctx, hint);
728
757
  if (own !== null) parts.push(own);
729
758
  }
@@ -742,7 +771,8 @@ function ownShape(node, ctx, hint) {
742
771
 
743
772
  const hasObjectKeywords = node.properties !== undefined
744
773
  || node.patternProperties !== undefined
745
- || node.additionalProperties !== undefined;
774
+ || node.additionalProperties !== undefined
775
+ || Array.isArray(node.required);
746
776
  const hasArrayKeywords = node.items !== undefined || node.prefixItems !== undefined;
747
777
 
748
778
  // No `type`: the applicator keywords describe the container cases, but
@@ -786,6 +816,7 @@ function ownShape(node, ctx, hint) {
786
816
  }
787
817
  else alternatives.push(T.unknown());
788
818
  }
819
+ if (node.nullable === true) alternatives.push(T.primitive('null'));
789
820
  return unionOf(alternatives);
790
821
  }
791
822
 
@@ -812,7 +843,7 @@ function objectShape(node, ctx, hint) {
812
843
  const member = {
813
844
  kind: 'member',
814
845
  name: key,
815
- type: typeOf(sub, ctx, `${hint}${toIdentifier(key)}`),
846
+ type: typeOf(sub, ctx, `${hint}${toIdentifier(key)}`, true),
816
847
  required: defaulted
817
848
  ? ctx.variant !== 'accepted'
818
849
  : declaredRequired,
@@ -828,7 +859,7 @@ function objectShape(node, ctx, hint) {
828
859
  for (const keyword of declaredExtensions) {
829
860
  if (subNode[keyword] === undefined) continue;
830
861
  if (carried === null) carried = {};
831
- carried[keyword] = subNode[keyword];
862
+ setObjectMember(carried, keyword, subNode[keyword]);
832
863
  }
833
864
  if (carried !== null) member.extensions = carried;
834
865
  }
@@ -836,6 +867,13 @@ function objectShape(node, ctx, hint) {
836
867
  }
837
868
  }
838
869
 
870
+ // `required` owns presence independently of `properties`. A required
871
+ // undeclared member has no narrower named schema to infer here.
872
+ for (const key of required) {
873
+ if (members.some((member) => member.name === key)) continue;
874
+ members.push({ kind: 'member', name: key, type: T.unknown(), required: true, constraints: [], doc: [] });
875
+ }
876
+
839
877
  // An index signature comes from additionalProperties or patternProperties.
840
878
  // `additionalProperties: false` is the closed case and adds nothing; `true`
841
879
  // or a schema opens the object up.