@patterkit/core 0.9.6 → 0.10.1

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/dist/index.cjs CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  hash4: () => hash4,
28
28
  humanizeNodeRefs: () => humanizeNodeRefs,
29
29
  isValidGameId: () => import_model5.isValidGameId,
30
+ neverRunNodes: () => neverRunNodes,
30
31
  newId: () => import_toolkit.newId,
31
32
  parseSource: () => import_toolkit3.parseSource,
32
33
  planProject: () => planProject,
@@ -97,6 +98,49 @@ var import_model = require("@patterkit/model");
97
98
  var import_model2 = require("@patterkit/model");
98
99
  var import_model3 = require("@patterkit/model");
99
100
  var import_expr = require("@wildwinter/expr");
101
+
102
+ // src/never-runs.ts
103
+ var isNode = (n) => !!n && typeof n === "object" && typeof n.id === "string";
104
+ function neverRunNodes(children) {
105
+ const out = [];
106
+ walkContainer(children, "run", out);
107
+ return out;
108
+ }
109
+ function walkContainer(children, selector, out) {
110
+ if (!Array.isArray(children)) return void 0;
111
+ if (selector === "run") {
112
+ let jumper;
113
+ for (const n of children) {
114
+ if (!isNode(n)) continue;
115
+ if (jumper !== void 0) {
116
+ out.push({ id: n.id, type: n.type, reason: "after-jump", ...jumper });
117
+ continue;
118
+ }
119
+ if (n.type === "snippet") {
120
+ if (!n.condition && n.jump && typeof n.jump.to === "string" && n.jump.mode !== "call") jumper = { jumper: n.id, to: n.jump.to };
121
+ continue;
122
+ }
123
+ const inner = walkContainer(n.children, n.selector ?? "run", out);
124
+ if (inner !== void 0 && !n.condition && !n.prompt && (n.selector ?? "run") === "run") jumper = inner;
125
+ }
126
+ return jumper;
127
+ }
128
+ let winner;
129
+ for (const n of children) {
130
+ if (!isNode(n)) continue;
131
+ if (selector === "branch") {
132
+ if (winner !== void 0) {
133
+ out.push({ id: n.id, type: n.type, reason: "branch-shadowed", by: winner });
134
+ continue;
135
+ }
136
+ if (!n.condition) winner = n.id;
137
+ }
138
+ if (n.type === "group") walkContainer(n.children, n.selector ?? "run", out);
139
+ }
140
+ return void 0;
141
+ }
142
+
143
+ // src/validate.ts
100
144
  function choiceLoopsBack(choice, ...targets) {
101
145
  const back = new Set(targets.filter((s) => typeof s === "string" && s.length > 0));
102
146
  const hits = (n) => n.type === "snippet" ? !!n.jump && back.has(n.jump.to) : (n.children ?? []).some(hits);
@@ -306,6 +350,17 @@ function validateProject(input) {
306
350
  } else {
307
351
  reportEmpty(block.children);
308
352
  }
353
+ for (const n of neverRunNodes(block.children)) {
354
+ if (n.reason !== "after-jump") continue;
355
+ const where2 = n.to === "END" ? "to END" : `to '${n.to}'`;
356
+ issues.push({
357
+ code: "unreachable-after-jump",
358
+ severity: "warning",
359
+ id: n.id,
360
+ jumper: n.jumper,
361
+ message: `${n.type} '${n.id}' in block '${block.id}' never runs: snippet '${n.jumper}' before it always jumps ${where2}`
362
+ });
363
+ }
309
364
  (0, import_model.walkNodes)(block.children, (node) => {
310
365
  const inBlock = `block '${block.id}'`;
311
366
  checkTags(node.tags, `${node.type} '${node.id}'`, node.id);
@@ -404,6 +459,32 @@ function validateProject(input) {
404
459
  });
405
460
  }
406
461
  }
462
+ const wellFormed = scenes.filter((sc) => typeof sc?.id === "string" && Array.isArray(sc.blocks));
463
+ if (wellFormed.length > 0) {
464
+ const start = project.start;
465
+ const scene = start ? wellFormed.find((sc) => sc.id === start.scene) : void 0;
466
+ if (!start) {
467
+ issues.push({
468
+ code: "no-start",
469
+ severity: "warning",
470
+ message: "no start scene is set, so a game that doesn't name a scene starts at the first one"
471
+ });
472
+ } else if (!scene) {
473
+ issues.push({
474
+ code: "dangling-start",
475
+ severity: "warning",
476
+ id: start.scene,
477
+ message: `the start scene '${start.scene}' isn't in the project, so a game that doesn't name a scene starts at the first one`
478
+ });
479
+ } else if (start.block !== void 0 && !scene.blocks.some((b) => b?.id === start.block)) {
480
+ issues.push({
481
+ code: "dangling-start",
482
+ severity: "warning",
483
+ id: start.block,
484
+ message: `the start block '${start.block}' isn't in scene '${scene.id}', so the game starts at that scene's first block`
485
+ });
486
+ }
487
+ }
407
488
  return issues;
408
489
  }
409
490
  function isQualifierEntry(q) {
@@ -776,6 +857,7 @@ function planScene(target, scaffold) {
776
857
  hash4,
777
858
  humanizeNodeRefs,
778
859
  isValidGameId,
860
+ neverRunNodes,
779
861
  newId,
780
862
  parseSource,
781
863
  planProject,
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { newId, parseSource } from '@wildwinter/toolkit';
2
- import { ProjectFile, Scene, AuthoringFile } from '@patterkit/model';
2
+ import { ProjectFile, Scene, AuthoringFile, Selector } from '@patterkit/model';
3
3
  export { effectiveGameId, gameIdify, isValidGameId } from '@patterkit/model';
4
4
 
5
5
  /** Slugify an author name into a filename- / address-safe segment. */
@@ -26,7 +26,7 @@ interface StringifyOptions {
26
26
  declare function canonicalStringify(value: unknown, opts?: StringifyOptions): string;
27
27
 
28
28
  interface ValidationIssue {
29
- code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-speaker-label" | "unused-speaker-label" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "invalid-gamedata-value" | "jump-cycle";
29
+ code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-speaker-label" | "unused-speaker-label" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "invalid-gamedata-value" | "jump-cycle" | "unreachable-after-jump" | "no-start" | "dangling-start";
30
30
  message: string;
31
31
  /** Id of the offending node/beat, where applicable. */
32
32
  id?: string;
@@ -40,6 +40,8 @@ interface ValidationIssue {
40
40
  bad: string;
41
41
  options: string[];
42
42
  };
43
+ /** `unreachable-after-jump`: the snippet whose jump means this node never runs (`id` is the node). */
44
+ jumper?: string;
43
45
  }
44
46
  interface ProjectInput {
45
47
  project: ProjectFile;
@@ -50,6 +52,43 @@ interface ProjectInput {
50
52
  /** Validate a project's structural invariants. Returns an empty array when valid. */
51
53
  declare function validateProject(input: ProjectInput): ValidationIssue[];
52
54
 
55
+ /** The shape the walk reads: an authored node (condition as source text) or a compiled one (an
56
+ * expression), so the validator and coverage both pass theirs as they are. */
57
+ interface RunTreeNode {
58
+ id: string;
59
+ type: "group" | "snippet";
60
+ condition?: unknown;
61
+ selector?: Selector;
62
+ prompt?: unknown;
63
+ children?: readonly RunTreeNode[];
64
+ jump?: {
65
+ to: string;
66
+ mode?: "jump" | "call";
67
+ };
68
+ }
69
+ /** A node that never runs, and why. Only the outermost such node is listed: everything inside it never
70
+ * runs either. */
71
+ type NeverRuns =
72
+ /** After an unconditioned snippet that jumps, in the same run. `jumper` is that snippet (inside a plain
73
+ * run group before this node, when that is where the jump is); `to` is where it jumps (`END` included). */
74
+ {
75
+ id: string;
76
+ type: "group" | "snippet";
77
+ reason: "after-jump";
78
+ jumper: string;
79
+ to: string;
80
+ }
81
+ /** In a `branch`, after a sibling with no condition, which is always picked first. */
82
+ | {
83
+ id: string;
84
+ type: "group" | "snippet";
85
+ reason: "branch-shadowed";
86
+ by: string;
87
+ };
88
+ /** The nodes in a block that can never run, in script order. `children` is the block's children (a run).
89
+ * Malformed shapes (a missing children array, a node that is not one) are skipped, never thrown on. */
90
+ declare function neverRunNodes(children: unknown): NeverRuns[];
91
+
53
92
  interface SceneScaffold {
54
93
  /** The scene's name: the card's title. */
55
94
  name: string;
@@ -98,4 +137,4 @@ declare function planProject(opts: {
98
137
  /** Plan a stub scene for a card. Pure: nothing is read or written. */
99
138
  declare function planScene(target: ScenePlanTarget, scaffold: SceneScaffold): ScenePlan;
100
139
 
101
- export { type ProjectInput, type ScenePlan, type ScenePlanTarget, type SceneScaffold, type StringifyOptions, type ValidationIssue, canonicalStringify, hash32, hash4, humanizeNodeRefs, planProject, planScene, slug, validateProject };
140
+ export { type NeverRuns, type ProjectInput, type RunTreeNode, type ScenePlan, type ScenePlanTarget, type SceneScaffold, type StringifyOptions, type ValidationIssue, canonicalStringify, hash32, hash4, humanizeNodeRefs, neverRunNodes, planProject, planScene, slug, validateProject };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { newId, parseSource } from '@wildwinter/toolkit';
2
- import { ProjectFile, Scene, AuthoringFile } from '@patterkit/model';
2
+ import { ProjectFile, Scene, AuthoringFile, Selector } from '@patterkit/model';
3
3
  export { effectiveGameId, gameIdify, isValidGameId } from '@patterkit/model';
4
4
 
5
5
  /** Slugify an author name into a filename- / address-safe segment. */
@@ -26,7 +26,7 @@ interface StringifyOptions {
26
26
  declare function canonicalStringify(value: unknown, opts?: StringifyOptions): string;
27
27
 
28
28
  interface ValidationIssue {
29
- code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-speaker-label" | "unused-speaker-label" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "invalid-gamedata-value" | "jump-cycle";
29
+ code: "duplicate-id" | "missing-id" | "malformed-node" | "missing-name" | "empty-scene" | "empty-snippet" | "empty-container" | "dangling-jump" | "jump-into-non-addressable" | "invalid-gameid" | "duplicate-gameid" | "unknown-character" | "unknown-qualifier" | "invalid-qualifier" | "invalid-pad" | "pad-overlaps-seam" | "prompt-pad-without-beat" | "qualifier-without-speaker" | "invalid-speaker-label" | "unused-speaker-label" | "invalid-temporary" | "invalid-declaration" | "invalid-status-ladder" | "invalid-locales" | "invalid-captions" | "missing-prompt" | "invalid-prompt" | "multiple-fallbacks" | "choice-can-empty" | "invalid-status-value" | "unknown-doc-class" | "invalid-tag" | "invalid-gamedata-field" | "invalid-gamedata-value" | "jump-cycle" | "unreachable-after-jump" | "no-start" | "dangling-start";
30
30
  message: string;
31
31
  /** Id of the offending node/beat, where applicable. */
32
32
  id?: string;
@@ -40,6 +40,8 @@ interface ValidationIssue {
40
40
  bad: string;
41
41
  options: string[];
42
42
  };
43
+ /** `unreachable-after-jump`: the snippet whose jump means this node never runs (`id` is the node). */
44
+ jumper?: string;
43
45
  }
44
46
  interface ProjectInput {
45
47
  project: ProjectFile;
@@ -50,6 +52,43 @@ interface ProjectInput {
50
52
  /** Validate a project's structural invariants. Returns an empty array when valid. */
51
53
  declare function validateProject(input: ProjectInput): ValidationIssue[];
52
54
 
55
+ /** The shape the walk reads: an authored node (condition as source text) or a compiled one (an
56
+ * expression), so the validator and coverage both pass theirs as they are. */
57
+ interface RunTreeNode {
58
+ id: string;
59
+ type: "group" | "snippet";
60
+ condition?: unknown;
61
+ selector?: Selector;
62
+ prompt?: unknown;
63
+ children?: readonly RunTreeNode[];
64
+ jump?: {
65
+ to: string;
66
+ mode?: "jump" | "call";
67
+ };
68
+ }
69
+ /** A node that never runs, and why. Only the outermost such node is listed: everything inside it never
70
+ * runs either. */
71
+ type NeverRuns =
72
+ /** After an unconditioned snippet that jumps, in the same run. `jumper` is that snippet (inside a plain
73
+ * run group before this node, when that is where the jump is); `to` is where it jumps (`END` included). */
74
+ {
75
+ id: string;
76
+ type: "group" | "snippet";
77
+ reason: "after-jump";
78
+ jumper: string;
79
+ to: string;
80
+ }
81
+ /** In a `branch`, after a sibling with no condition, which is always picked first. */
82
+ | {
83
+ id: string;
84
+ type: "group" | "snippet";
85
+ reason: "branch-shadowed";
86
+ by: string;
87
+ };
88
+ /** The nodes in a block that can never run, in script order. `children` is the block's children (a run).
89
+ * Malformed shapes (a missing children array, a node that is not one) are skipped, never thrown on. */
90
+ declare function neverRunNodes(children: unknown): NeverRuns[];
91
+
53
92
  interface SceneScaffold {
54
93
  /** The scene's name: the card's title. */
55
94
  name: string;
@@ -98,4 +137,4 @@ declare function planProject(opts: {
98
137
  /** Plan a stub scene for a card. Pure: nothing is read or written. */
99
138
  declare function planScene(target: ScenePlanTarget, scaffold: SceneScaffold): ScenePlan;
100
139
 
101
- export { type ProjectInput, type ScenePlan, type ScenePlanTarget, type SceneScaffold, type StringifyOptions, type ValidationIssue, canonicalStringify, hash32, hash4, humanizeNodeRefs, planProject, planScene, slug, validateProject };
140
+ export { type NeverRuns, type ProjectInput, type RunTreeNode, type ScenePlan, type ScenePlanTarget, type SceneScaffold, type StringifyOptions, type ValidationIssue, canonicalStringify, hash32, hash4, humanizeNodeRefs, neverRunNodes, planProject, planScene, slug, validateProject };
package/dist/index.js CHANGED
@@ -59,6 +59,49 @@ import { walkNodes, DEFAULT_WRITING_STATUSES, DEFAULT_RECORDING_STATUSES, DEFAUL
59
59
  import { isValidGameId, effectiveGameId } from "@patterkit/model";
60
60
  import { isValidPropertyName, propertyNameify, RESERVED_PROPERTY_NAMES } from "@patterkit/model";
61
61
  import { qualityDeclarationProblems, stagesEitherSide, valueFits } from "@wildwinter/expr";
62
+
63
+ // src/never-runs.ts
64
+ var isNode = (n) => !!n && typeof n === "object" && typeof n.id === "string";
65
+ function neverRunNodes(children) {
66
+ const out = [];
67
+ walkContainer(children, "run", out);
68
+ return out;
69
+ }
70
+ function walkContainer(children, selector, out) {
71
+ if (!Array.isArray(children)) return void 0;
72
+ if (selector === "run") {
73
+ let jumper;
74
+ for (const n of children) {
75
+ if (!isNode(n)) continue;
76
+ if (jumper !== void 0) {
77
+ out.push({ id: n.id, type: n.type, reason: "after-jump", ...jumper });
78
+ continue;
79
+ }
80
+ if (n.type === "snippet") {
81
+ if (!n.condition && n.jump && typeof n.jump.to === "string" && n.jump.mode !== "call") jumper = { jumper: n.id, to: n.jump.to };
82
+ continue;
83
+ }
84
+ const inner = walkContainer(n.children, n.selector ?? "run", out);
85
+ if (inner !== void 0 && !n.condition && !n.prompt && (n.selector ?? "run") === "run") jumper = inner;
86
+ }
87
+ return jumper;
88
+ }
89
+ let winner;
90
+ for (const n of children) {
91
+ if (!isNode(n)) continue;
92
+ if (selector === "branch") {
93
+ if (winner !== void 0) {
94
+ out.push({ id: n.id, type: n.type, reason: "branch-shadowed", by: winner });
95
+ continue;
96
+ }
97
+ if (!n.condition) winner = n.id;
98
+ }
99
+ if (n.type === "group") walkContainer(n.children, n.selector ?? "run", out);
100
+ }
101
+ return void 0;
102
+ }
103
+
104
+ // src/validate.ts
62
105
  function choiceLoopsBack(choice, ...targets) {
63
106
  const back = new Set(targets.filter((s) => typeof s === "string" && s.length > 0));
64
107
  const hits = (n) => n.type === "snippet" ? !!n.jump && back.has(n.jump.to) : (n.children ?? []).some(hits);
@@ -268,6 +311,17 @@ function validateProject(input) {
268
311
  } else {
269
312
  reportEmpty(block.children);
270
313
  }
314
+ for (const n of neverRunNodes(block.children)) {
315
+ if (n.reason !== "after-jump") continue;
316
+ const where2 = n.to === "END" ? "to END" : `to '${n.to}'`;
317
+ issues.push({
318
+ code: "unreachable-after-jump",
319
+ severity: "warning",
320
+ id: n.id,
321
+ jumper: n.jumper,
322
+ message: `${n.type} '${n.id}' in block '${block.id}' never runs: snippet '${n.jumper}' before it always jumps ${where2}`
323
+ });
324
+ }
271
325
  walkNodes(block.children, (node) => {
272
326
  const inBlock = `block '${block.id}'`;
273
327
  checkTags(node.tags, `${node.type} '${node.id}'`, node.id);
@@ -366,6 +420,32 @@ function validateProject(input) {
366
420
  });
367
421
  }
368
422
  }
423
+ const wellFormed = scenes.filter((sc) => typeof sc?.id === "string" && Array.isArray(sc.blocks));
424
+ if (wellFormed.length > 0) {
425
+ const start = project.start;
426
+ const scene = start ? wellFormed.find((sc) => sc.id === start.scene) : void 0;
427
+ if (!start) {
428
+ issues.push({
429
+ code: "no-start",
430
+ severity: "warning",
431
+ message: "no start scene is set, so a game that doesn't name a scene starts at the first one"
432
+ });
433
+ } else if (!scene) {
434
+ issues.push({
435
+ code: "dangling-start",
436
+ severity: "warning",
437
+ id: start.scene,
438
+ message: `the start scene '${start.scene}' isn't in the project, so a game that doesn't name a scene starts at the first one`
439
+ });
440
+ } else if (start.block !== void 0 && !scene.blocks.some((b) => b?.id === start.block)) {
441
+ issues.push({
442
+ code: "dangling-start",
443
+ severity: "warning",
444
+ id: start.block,
445
+ message: `the start block '${start.block}' isn't in scene '${scene.id}', so the game starts at that scene's first block`
446
+ });
447
+ }
448
+ }
369
449
  return issues;
370
450
  }
371
451
  function isQualifierEntry(q) {
@@ -737,6 +817,7 @@ export {
737
817
  hash4,
738
818
  humanizeNodeRefs,
739
819
  isValidGameId2 as isValidGameId,
820
+ neverRunNodes,
740
821
  newId,
741
822
  parseSource,
742
823
  planProject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/core",
3
- "version": "0.9.6",
3
+ "version": "0.10.1",
4
4
  "description": "Patter core: stable id generation, readable-handle computation, canonical (de)serialisation, and the structural validator.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,9 +34,9 @@
34
34
  "build": "tsup src/index.ts --format esm,cjs --dts --clean"
35
35
  },
36
36
  "dependencies": {
37
- "@patterkit/model": "0.20.0",
37
+ "@patterkit/model": "0.22.0",
38
38
  "@wildwinter/expr": "^0.6.0",
39
- "@wildwinter/toolkit": "^0.3.1",
39
+ "@wildwinter/toolkit": "^0.4.0",
40
40
  "json5": "^2.2.3"
41
41
  }
42
42
  }