@patterkit/ops 0.4.0 → 0.5.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/dist/index.d.ts CHANGED
@@ -80,6 +80,10 @@ interface ValidateResult {
80
80
  hygiene: HygieneIssue[];
81
81
  /** Committed `.patterc` bundles whose hash no longer matches source (spec §11). */
82
82
  staleBundles: HygieneIssue[];
83
+ /** Conditions provably unsatisfiable over monotonic latches (reachability.ts). WARNINGS, and
84
+ * deliberately NOT part of `ok`: content is written in pieces, so a gate whose writer has not been
85
+ * authored yet is the normal state mid-work and must never fail a build. */
86
+ reachability: ConditionIssue[];
83
87
  /** Lingering `.patterconflict` sidecars - an unresolved merge (patter-merge.md §3.6). */
84
88
  unresolvedMerges: HygieneIssue[];
85
89
  /** Patter shards on disk that the project does NOT contain - see `orphanShards`. */
@@ -89,6 +93,12 @@ interface ValidateResult {
89
93
  /** Run structural + expression + interpolation + hygiene + bundle-staleness + merge validation. */
90
94
  declare function runValidate(loaded: LoadedProject): ValidateResult;
91
95
 
96
+ /**
97
+ * Conditions that can never hold, as warnings. Empty for a project with no monotonic latches, which is
98
+ * most of them: this only speaks when it can prove an ordering.
99
+ */
100
+ declare function reachabilityIssues(loaded: LoadedProject, compiled?: Bundle): ConditionIssue[];
101
+
92
102
  declare function runExport(loaded: LoadedProject): Bundle;
93
103
  /** Compile WITHOUT the localisation-mode transform - the full bundle with every locale inline. Used where
94
104
  * the host needs all strings regardless of build mode (e.g. Patterpad's Play window + Export Localisation). */
@@ -211,6 +221,19 @@ interface CoverageBeat {
211
221
  /** Set on a never-reached beat that is gated on a host-scope ref (`@world.x`) nothing writes and no
212
222
  * driver provides, i.e. it may just need an input, not be truly dead. Lists the offending refs. */
213
223
  needsInput?: string[];
224
+ /** Set on a never-reached beat gated on a ref that IS written, but ONLY by content that was itself
225
+ * never reached: the beat is dead at one remove, and the gate is not the real question. Names the
226
+ * ref and the beats that witness its only writers, so two mysteries collapse into one. Absent
227
+ * whenever the chain cannot be refuted (see `writerSites` in the analysis). */
228
+ blockedBy?: BlockedGate[];
229
+ }
230
+ /** A gate on a never-reached beat whose every writer was itself never reached. */
231
+ interface BlockedGate {
232
+ /** The gating ref, at the granularity the condition reads it: `@world.alarm`, or `@world.mood:armed`
233
+ * for a single flag of a flags property. */
234
+ ref: string;
235
+ /** Beat ids witnessing the writers: content that would have to play for this gate to be written. */
236
+ writers: string[];
214
237
  }
215
238
  /** A choice that ran DRY during the coverage run: at some point it had no takeable option and no eligible
216
239
  * fallback, so it fell through silently and the flow carried on past it. This is easy to author by
@@ -907,4 +930,4 @@ declare function classesForChannel(classes: DocumentationClass[], channel: strin
907
930
  */
908
931
  declare function resolveDocumentation(loaded: LoadedProject, channel: string): Map<string, DocLine[]>;
909
932
 
910
- export { AUTHORING_HANDLED, type ApplyStats, type BundlePosture, CONFLICT_SIDECAR, type CharacterReport, type Conflict, type ConflictKind, type CoverageAsyncHooks, type CoverageBeat, type CoverageHooks, type CoverageOptions, type CoverageReport, type DocumentManifest, type FormatResult, type HygieneIssue, type InitOptions, type InitResult, type InitVcs, type LoadedProject, type LocCatalog, type LocEntry, type LocaleReport, type MergeFileType, type MergeResult, type MergedShard, type PlannedWrite, type PlayEvent, type PlayOptions, type PlayOutcome, type PlayResult, type ProvenanceCheck, type ReplaceHit, type ReplaceOptions, type ReplacePlan, type ReportData, type ReportTotals, type ResolveEntry, SHARD_EXTENSIONS, type SceneReport, type ScriptDoc, type ScriptElement, type SearchFocus, type UnpackMergeResult, UnsafeEntryError, UnsupportedMergeError, type ValidateResult, type VoiceLine, type VoiceScript, type VoicedCounts, type WebExport, type WrittenCounts, applyLoc, applySceneOrder, applyWrites, bundleOutputPath, catalogToJson, catalogToPo, catalogToXlsx, classesForChannel, detectMergeType, extractLoc, findProjectFile, isUnsafeEntry, jsonToCatalog, listProjectTags, loadProject, loadProjectLanding, poToCatalog, proposeCoverageDrivers, renderCoverageText, renderPlay, renderReportText, resolveDocumentation, resolveStart, runCoverage, runCoverageAsync, runExport, runExportFull, runExportHtml, runExportWeb, runFormat, runInit, runMerge, runPack, runPlay, runPropertyUsage, runReplace, runReport, runReportXlsx, runResolve, runScriptDoc, runSearch, runStatusBrowse, runTagBrowse, runUnpack, runUnpackMerge, runValidate, runVoiceScript, scanAudioStatus, sceneIdForShard, scriptToDocx, scriptToPdf, sidecarIssues, vcsConfigWrites, voiceScriptToXlsx, xlsxToCatalog };
933
+ export { AUTHORING_HANDLED, type ApplyStats, type BundlePosture, CONFLICT_SIDECAR, type CharacterReport, type Conflict, type ConflictKind, type CoverageAsyncHooks, type CoverageBeat, type CoverageHooks, type CoverageOptions, type CoverageReport, type DocumentManifest, type FormatResult, type HygieneIssue, type InitOptions, type InitResult, type InitVcs, type LoadedProject, type LocCatalog, type LocEntry, type LocaleReport, type MergeFileType, type MergeResult, type MergedShard, type PlannedWrite, type PlayEvent, type PlayOptions, type PlayOutcome, type PlayResult, type ProvenanceCheck, type ReplaceHit, type ReplaceOptions, type ReplacePlan, type ReportData, type ReportTotals, type ResolveEntry, SHARD_EXTENSIONS, type SceneReport, type ScriptDoc, type ScriptElement, type SearchFocus, type UnpackMergeResult, UnsafeEntryError, UnsupportedMergeError, type ValidateResult, type VoiceLine, type VoiceScript, type VoicedCounts, type WebExport, type WrittenCounts, applyLoc, applySceneOrder, applyWrites, bundleOutputPath, catalogToJson, catalogToPo, catalogToXlsx, classesForChannel, detectMergeType, extractLoc, findProjectFile, isUnsafeEntry, jsonToCatalog, listProjectTags, loadProject, loadProjectLanding, poToCatalog, proposeCoverageDrivers, reachabilityIssues, renderCoverageText, renderPlay, renderReportText, resolveDocumentation, resolveStart, runCoverage, runCoverageAsync, runExport, runExportFull, runExportHtml, runExportWeb, runFormat, runInit, runMerge, runPack, runPlay, runPropertyUsage, runReplace, runReport, runReportXlsx, runResolve, runScriptDoc, runSearch, runStatusBrowse, runTagBrowse, runUnpack, runUnpackMerge, runValidate, runVoiceScript, scanAudioStatus, sceneIdForShard, scriptToDocx, scriptToPdf, sidecarIssues, vcsConfigWrites, voiceScriptToXlsx, xlsxToCatalog };
package/dist/index.js CHANGED
@@ -571,7 +571,205 @@ var arrEq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
571
571
 
572
572
  // src/validate.ts
573
573
  import { validateProject, parseSource as parseSource2 } from "@patterkit/core";
574
- import { validateConditions, validateInterpolation, exportBundle, hostScopesToSpec } from "@patterkit/compiler";
574
+ import { validateConditions, validateInterpolation, exportBundle as exportBundle2, hostScopesToSpec } from "@patterkit/compiler";
575
+
576
+ // src/reachability.ts
577
+ import { exportBundle } from "@patterkit/compiler";
578
+ var normaliseRef = (ref) => ref.includes(".") ? ref : `@patter.${ref.slice(1)}`;
579
+ var SEP = "\0";
580
+ var keyOf = (ref, sceneId) => ref.startsWith("@scene.") ? `${sceneId}${SEP}${ref}` : ref;
581
+ var shown = (key) => {
582
+ const bare = key.includes(SEP) ? key.slice(key.indexOf(SEP) + 1) : key;
583
+ const at = bare.indexOf(":");
584
+ return at < 0 ? bare : `${bare.slice(0, at)} +${bare.slice(at + 1)}`;
585
+ };
586
+ var svRef = (ast) => Array.isArray(ast) && ast[0] === "sv" ? `@${String(ast[1])}.${String(ast[2])}` : void 0;
587
+ function latchOf(ast, sceneId) {
588
+ const direct = svRef(ast);
589
+ if (direct !== void 0) return keyOf(direct, sceneId);
590
+ if (!Array.isArray(ast)) return void 0;
591
+ if (ast[0] === "bin" && ast[1] === "==") {
592
+ const [, , l, r] = ast;
593
+ for (const [a, b] of [[l, r], [r, l]]) {
594
+ const ref = svRef(a);
595
+ if (ref !== void 0 && Array.isArray(b) && b[0] === "b" && b[1] === true) return keyOf(ref, sceneId);
596
+ }
597
+ return void 0;
598
+ }
599
+ if (ast[0] === "call" && ast[1] === "check_flags") {
600
+ const ref = svRef(ast[2]);
601
+ const args = ast.slice(3);
602
+ if (ref === void 0 || args.length !== 1) return void 0;
603
+ const arg = args[0];
604
+ if (Array.isArray(arg) && arg[0] === "fd" && arg[1] === "+") return `${keyOf(ref, sceneId)}:${String(arg[2])}`;
605
+ }
606
+ return void 0;
607
+ }
608
+ function disjuncts(ast) {
609
+ if (Array.isArray(ast) && ast[0] === "bin" && ast[1] === "or") {
610
+ const [, , l, r] = ast;
611
+ return [...disjuncts(l), ...disjuncts(r)];
612
+ }
613
+ return [ast];
614
+ }
615
+ function terms(ast, sceneId, negated = false, into = []) {
616
+ const latch = latchOf(ast, sceneId);
617
+ if (latch !== void 0) {
618
+ into.push({ key: latch, negated });
619
+ return into;
620
+ }
621
+ if (!Array.isArray(ast)) return into;
622
+ if (ast[0] === "u" && ast[1] === "not") return terms(ast[2], sceneId, !negated, into);
623
+ if (ast[0] === "bin" && ast[1] === "and" && !negated) {
624
+ terms(ast[2], sceneId, false, into);
625
+ terms(ast[3], sceneId, false, into);
626
+ }
627
+ return into;
628
+ }
629
+ var requirementsOf = (expr, sceneId) => expr === void 0 ? [] : terms(expr.ast, sceneId);
630
+ var asFlags = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
631
+ function reachabilityIssues(loaded, compiled) {
632
+ const bundle = compiled ?? exportBundle({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
633
+ const hostTokens = new Set((loaded.project.scopeRegistry?.scopes ?? []).map((s) => s.token));
634
+ const preset = /* @__PURE__ */ new Set();
635
+ const notALatch = (decl, key) => {
636
+ if (decl.temporary) {
637
+ preset.add(key);
638
+ for (const v of decl.values ?? []) preset.add(`${key}:${v}`);
639
+ return;
640
+ }
641
+ if (decl.type === "boolean" && decl.default === true) preset.add(key);
642
+ if (decl.type === "flags") for (const v of asFlags(decl.default)) preset.add(`${key}:${v}`);
643
+ };
644
+ for (const decl of loaded.project.properties ?? []) notALatch(decl, `@patter.${decl.name}`);
645
+ for (const scene of loaded.scenes) {
646
+ for (const decl of scene.sceneProps ?? []) notALatch(decl, `${scene.id}${SEP}@scene.${decl.name}`);
647
+ }
648
+ const latched = /* @__PURE__ */ new Set();
649
+ const broken = /* @__PURE__ */ new Set();
650
+ const writers = /* @__PURE__ */ new Map();
651
+ const noteWrite = (key, requires) => {
652
+ latched.add(key);
653
+ (writers.get(key) ?? writers.set(key, []).get(key)).push({ requires });
654
+ };
655
+ const breakKey = (key) => {
656
+ broken.add(key);
657
+ for (const k of latched) if (k.startsWith(`${key}:`)) broken.add(k);
658
+ };
659
+ const scanEffects = (effects, sceneId, need) => {
660
+ for (const e of effects ?? []) {
661
+ const ref = normaliseRef(e.target);
662
+ const key = keyOf(ref, sceneId);
663
+ const ast = e.value.ast;
664
+ if (Array.isArray(ast) && ast[0] === "b" && ast[1] === true) {
665
+ noteWrite(key, need);
666
+ continue;
667
+ }
668
+ if (Array.isArray(ast) && ast[0] === "call" && ast[1] === "set_flags" && svRef(ast[2]) === ref) {
669
+ let clean = true;
670
+ for (const arg of ast.slice(3)) {
671
+ if (Array.isArray(arg) && arg[0] === "fd" && arg[1] === "+") noteWrite(`${key}:${String(arg[2])}`, need);
672
+ else clean = false;
673
+ }
674
+ if (clean) continue;
675
+ }
676
+ breakKey(key);
677
+ }
678
+ };
679
+ const walk = (nodes, sceneId, gate) => {
680
+ for (const node of nodes) {
681
+ const here = [...gate, ...requirementsOf(node.condition, sceneId)];
682
+ if (node.type === "group") walk(node.children, sceneId, here);
683
+ else {
684
+ scanEffects(node.onEnter, sceneId, here);
685
+ scanEffects(node.onExit, sceneId, here);
686
+ }
687
+ }
688
+ };
689
+ for (const scene of Object.values(bundle.scenes)) {
690
+ scanEffects(scene.onEntry, scene.id, []);
691
+ for (const block of scene.blocks) walk(block.children, scene.id, []);
692
+ }
693
+ for (const key of [...latched]) {
694
+ const at = key.indexOf(":");
695
+ if (at > 0 && broken.has(key.slice(0, at))) broken.add(key);
696
+ }
697
+ const hostDriven = (key) => hostTokens.has(key.slice(1, key.indexOf(".")));
698
+ const monotonic = (key) => latched.has(key) && !broken.has(key) && !preset.has(key) && !hostDriven(key);
699
+ const cache = /* @__PURE__ */ new Map();
700
+ const inFlight = /* @__PURE__ */ new Set();
701
+ const mustHold = (key) => {
702
+ const done = cache.get(key);
703
+ if (done !== void 0) return { need: done, cut: false };
704
+ if (inFlight.has(key)) return { need: /* @__PURE__ */ new Set(), cut: true };
705
+ const routes = writers.get(key);
706
+ if (routes === void 0 || routes.length === 0) return { need: /* @__PURE__ */ new Set(), cut: false };
707
+ inFlight.add(key);
708
+ let shared;
709
+ let cut = false;
710
+ for (const route of routes) {
711
+ const need = /* @__PURE__ */ new Set();
712
+ for (const t of route.requires) {
713
+ if (t.negated || !monotonic(t.key)) continue;
714
+ need.add(t.key);
715
+ const deeper = mustHold(t.key);
716
+ cut ||= deeper.cut;
717
+ for (const k of deeper.need) need.add(k);
718
+ }
719
+ shared = shared === void 0 ? need : new Set([...shared].filter((k) => need.has(k)));
720
+ }
721
+ inFlight.delete(key);
722
+ const result = shared ?? /* @__PURE__ */ new Set();
723
+ if (!cut) cache.set(key, result);
724
+ return { need: result, cut };
725
+ };
726
+ const refute = (gate) => {
727
+ for (const no of gate.filter((t) => t.negated)) {
728
+ if (!monotonic(no.key)) continue;
729
+ if (gate.some((t) => !t.negated && t.key === no.key)) {
730
+ return `it asks for ${shown(no.key)} to be both set and not set`;
731
+ }
732
+ for (const yes of gate.filter((t) => !t.negated && t.key !== no.key)) {
733
+ if (!monotonic(yes.key)) continue;
734
+ const chain = mustHold(yes.key);
735
+ if (!chain.cut && chain.need.has(no.key)) {
736
+ return `${shown(yes.key)} can only become true after ${shown(no.key)}, which nothing sets back, so this condition can never hold`;
737
+ }
738
+ }
739
+ }
740
+ return void 0;
741
+ };
742
+ const issues = [];
743
+ const report = (nodes, sceneId, gate) => {
744
+ for (const node of nodes) {
745
+ const own = node.condition;
746
+ const branches = own ? disjuncts(own.ast) : [void 0];
747
+ let reason;
748
+ const refuted = branches.every((b) => {
749
+ const r = refute([...gate, ...b ? terms(b, sceneId) : []]);
750
+ reason ??= r;
751
+ return r !== void 0;
752
+ });
753
+ if (refuted && reason !== void 0 && own) {
754
+ issues.push({
755
+ nodeId: node.id,
756
+ field: "condition",
757
+ src: own.src,
758
+ severity: "warning",
759
+ message: `this can never run: ${reason}`
760
+ });
761
+ continue;
762
+ }
763
+ if (node.type === "group") report(node.children, sceneId, [...gate, ...requirementsOf(own, sceneId)]);
764
+ }
765
+ };
766
+ for (const scene of Object.values(bundle.scenes)) {
767
+ for (const block of scene.blocks) report(block.children, scene.id, []);
768
+ }
769
+ return issues;
770
+ }
771
+
772
+ // src/validate.ts
575
773
  function runValidate(loaded) {
576
774
  const { project, scenes, locales } = loaded;
577
775
  const structural = validateProject({ project, scenes, authoring: loaded.authoring });
@@ -582,10 +780,12 @@ function runValidate(loaded) {
582
780
  const staleBundles = checkBundles(loaded);
583
781
  const unresolvedMerges = sidecarIssues(walkFiles(loaded.root, CONFLICT_SIDECAR));
584
782
  const orphans = orphanShards(loaded);
783
+ const reachability = structural.length === 0 && conditions.length === 0 ? reachabilityIssues(loaded) : [];
585
784
  return {
586
785
  structural,
587
786
  conditions,
588
787
  interpolation,
788
+ reachability,
589
789
  hygiene,
590
790
  staleBundles,
591
791
  unresolvedMerges,
@@ -621,7 +821,7 @@ function checkBundles(loaded) {
621
821
  if (bundles.length === 0) return issues;
622
822
  let fresh;
623
823
  try {
624
- fresh = exportBundle({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales }).content.hash;
824
+ fresh = exportBundle2({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales }).content.hash;
625
825
  } catch {
626
826
  return issues;
627
827
  }
@@ -675,7 +875,7 @@ function checkHygiene(files) {
675
875
 
676
876
  // src/export.ts
677
877
  import { basename as basename2, isAbsolute, join as join2 } from "path";
678
- import { exportBundle as exportBundle2 } from "@patterkit/compiler";
878
+ import { exportBundle as exportBundle3 } from "@patterkit/compiler";
679
879
  import { isContentlessBeat } from "@patterkit/model";
680
880
  function compileScenes(loaded) {
681
881
  const def = loaded.project.locales.default;
@@ -707,7 +907,7 @@ function refuseUnresolvedMerge(loaded) {
707
907
  function runExport(loaded) {
708
908
  refuseUnresolvedMerge(loaded);
709
909
  const { project, locales } = loaded;
710
- const full = exportBundle2({ project, scenes: compileScenes(loaded), locales });
910
+ const full = exportBundle3({ project, scenes: compileScenes(loaded), locales });
711
911
  const loc = project.export?.localisation;
712
912
  if (!loc || loc.mode === "embedded") return full;
713
913
  const strings = loc.sourceDebug ? { [full.locales.default]: full.strings[full.locales.default] ?? {} } : {};
@@ -716,7 +916,7 @@ function runExport(loaded) {
716
916
  function runExportFull(loaded) {
717
917
  refuseUnresolvedMerge(loaded);
718
918
  const { project, locales } = loaded;
719
- return exportBundle2({ project, scenes: compileScenes(loaded), locales });
919
+ return exportBundle3({ project, scenes: compileScenes(loaded), locales });
720
920
  }
721
921
  function bundleOutputPath(loaded) {
722
922
  const override = loaded.project.export?.bundle;
@@ -953,7 +1153,7 @@ function scanAudioStatus(loaded) {
953
1153
  }
954
1154
 
955
1155
  // src/play.ts
956
- import { exportBundle as exportBundle3 } from "@patterkit/compiler";
1156
+ import { exportBundle as exportBundle4 } from "@patterkit/compiler";
957
1157
  import { Engine } from "@patterkit/runtime";
958
1158
 
959
1159
  // src/loaded-helpers.ts
@@ -1007,7 +1207,7 @@ function mergeAuthoring2(loaded) {
1007
1207
 
1008
1208
  // src/play.ts
1009
1209
  function runPlay(loaded, opts = {}) {
1010
- const bundle = exportBundle3({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
1210
+ const bundle = exportBundle4({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
1011
1211
  const events = [];
1012
1212
  const engine = new Engine(bundle, { seed: opts.seed });
1013
1213
  const start = resolveStart(loaded, opts);
@@ -1062,7 +1262,7 @@ function renderPlay(result) {
1062
1262
  }
1063
1263
 
1064
1264
  // src/coverage.ts
1065
- import { exportBundle as exportBundle4 } from "@patterkit/compiler";
1265
+ import { exportBundle as exportBundle5 } from "@patterkit/compiler";
1066
1266
  import { Engine as Engine2 } from "@patterkit/runtime";
1067
1267
  import { walkNodes } from "@patterkit/model";
1068
1268
 
@@ -1142,45 +1342,118 @@ function targetHostRef(target, hostTokens) {
1142
1342
  const m = /^@([A-Za-z_][\w]*)\.(.+)$/.exec(target);
1143
1343
  return m && hostTokens.has(m[1]) ? `@${m[1]}.${m[2]}` : void 0;
1144
1344
  }
1345
+ function flagKeys(node, hostTokens, fn) {
1346
+ if (node.kind !== "call" || node.name !== fn) return [];
1347
+ const subject = node.args[0];
1348
+ if (!subject || subject.kind !== "scopedvar" || !hostTokens.has(subject.scope)) return [];
1349
+ const ref = `@${subject.scope}.${subject.name}`;
1350
+ return node.args.slice(1).filter((a) => a.kind === "flagdelta").map((a) => `${ref}:${a.name}`);
1351
+ }
1352
+ function fineRefsIn(node, hostTokens, out) {
1353
+ for (const k of flagKeys(node, hostTokens, "check_flags")) out.add(k);
1354
+ switch (node.kind) {
1355
+ case "unary":
1356
+ fineRefsIn(node.operand, hostTokens, out);
1357
+ break;
1358
+ case "binary":
1359
+ fineRefsIn(node.left, hostTokens, out);
1360
+ fineRefsIn(node.right, hostTokens, out);
1361
+ break;
1362
+ case "call":
1363
+ for (const a of node.args) fineRefsIn(a, hostTokens, out);
1364
+ break;
1365
+ default:
1366
+ break;
1367
+ }
1368
+ }
1145
1369
  function analyzeHostScopes(bundle, hostTokens) {
1146
1370
  const written = /* @__PURE__ */ new Set();
1147
1371
  const gatesByBeat = /* @__PURE__ */ new Map();
1372
+ const fineGatesByBeat = /* @__PURE__ */ new Map();
1148
1373
  const proposals = /* @__PURE__ */ new Map();
1149
- if (hostTokens.size === 0) return { written, gatesByBeat, proposals };
1374
+ const writerSites = /* @__PURE__ */ new Map();
1375
+ const opaqueWrites = /* @__PURE__ */ new Set();
1376
+ const empty = { written, gatesByBeat, proposals, fineGatesByBeat, writerSites, opaqueWrites };
1377
+ if (hostTokens.size === 0) return empty;
1150
1378
  const refsIn = (expr) => {
1151
1379
  const refs = /* @__PURE__ */ new Set();
1152
1380
  if (expr) scanExpr(deserialiseAst(expr.ast), hostTokens, refs, proposals);
1153
1381
  return refs;
1154
1382
  };
1155
- const scanEffects = (effects) => {
1383
+ const fineIn = (expr) => {
1384
+ const refs = /* @__PURE__ */ new Set();
1385
+ if (expr) fineRefsIn(deserialiseAst(expr.ast), hostTokens, refs);
1386
+ return refs;
1387
+ };
1388
+ const addSite = (key, witnesses) => {
1389
+ (writerSites.get(key) ?? writerSites.set(key, []).get(key)).push(witnesses);
1390
+ };
1391
+ const scanEffects = (effects, witnesses) => {
1156
1392
  for (const e of effects ?? []) {
1157
- const t = targetHostRef(e.target, hostTokens);
1158
- if (t) written.add(t);
1393
+ const target = targetHostRef(e.target, hostTokens);
1394
+ if (target) {
1395
+ written.add(target);
1396
+ addSite(target, witnesses);
1397
+ const flags = e.value ? flagKeys(deserialiseAst(e.value.ast), hostTokens, "set_flags") : [];
1398
+ if (flags.length) for (const k of flags) addSite(k, witnesses);
1399
+ else opaqueWrites.add(target);
1400
+ }
1159
1401
  refsIn(e.value);
1160
1402
  }
1161
1403
  };
1162
- const walk = (nodes, gate) => {
1404
+ const walk = (nodes, gate, fine) => {
1163
1405
  for (const node of nodes) {
1164
1406
  const here = /* @__PURE__ */ new Set([...gate, ...refsIn(node.condition)]);
1407
+ const hereFine = /* @__PURE__ */ new Set([...fine, ...fineIn(node.condition)]);
1165
1408
  if (node.type === "group") {
1166
- walk(node.children, here);
1409
+ walk(node.children, here, hereFine);
1167
1410
  } else {
1168
- scanEffects(node.onEnter);
1169
- scanEffects(node.onExit);
1170
- for (const beat of node.beats ?? []) gatesByBeat.set(beat.id, here);
1411
+ const witnesses = (node.beats ?? []).map((b) => b.id);
1412
+ scanEffects(node.onEnter, witnesses);
1413
+ scanEffects(node.onExit, witnesses);
1414
+ for (const beat of node.beats ?? []) {
1415
+ gatesByBeat.set(beat.id, here);
1416
+ fineGatesByBeat.set(beat.id, hereFine);
1417
+ }
1171
1418
  }
1172
1419
  }
1173
1420
  };
1174
1421
  for (const scene of Object.values(bundle.scenes)) {
1175
- scanEffects(scene.onEntry);
1176
- for (const block of scene.blocks) walk(block.children, /* @__PURE__ */ new Set());
1422
+ const sceneBeats = [];
1423
+ const collect = (nodes) => {
1424
+ for (const n of nodes) {
1425
+ if (n.type === "group") collect(n.children);
1426
+ else for (const b of n.beats ?? []) sceneBeats.push(b.id);
1427
+ }
1428
+ };
1429
+ for (const block of scene.blocks) collect(block.children);
1430
+ scanEffects(scene.onEntry, sceneBeats);
1431
+ for (const block of scene.blocks) walk(block.children, /* @__PURE__ */ new Set(), /* @__PURE__ */ new Set());
1177
1432
  }
1178
- return { written, gatesByBeat, proposals };
1433
+ return empty;
1434
+ }
1435
+ function blockedGates(beatId, analysis, drivenRefs, reachedRuns) {
1436
+ const out = [];
1437
+ const gates = /* @__PURE__ */ new Set([...analysis.gatesByBeat.get(beatId) ?? [], ...analysis.fineGatesByBeat.get(beatId) ?? []]);
1438
+ for (const ref of [...gates].sort()) {
1439
+ const coarse = ref.includes(":") ? ref.slice(0, ref.indexOf(":")) : ref;
1440
+ if (drivenRefs.has(coarse)) continue;
1441
+ if (!analysis.written.has(coarse)) continue;
1442
+ if (ref !== coarse && analysis.opaqueWrites.has(coarse)) continue;
1443
+ if (ref === coarse && [...gates].some((g) => g !== ref && g.startsWith(`${ref}:`))) continue;
1444
+ const sites = analysis.writerSites.get(ref) ?? [];
1445
+ if (!sites.length) continue;
1446
+ if (sites.some((s) => !s.length || s.some((w) => !reachedRuns.has(w)))) continue;
1447
+ if (!sites.every((s) => s.every((w) => reachedRuns.get(w) === 0))) continue;
1448
+ const writers = [...new Set(sites.flat())].sort();
1449
+ out.push({ ref, writers });
1450
+ }
1451
+ return out;
1179
1452
  }
1180
1453
  function proposeCoverageDrivers(loaded) {
1181
1454
  const hostTokens = new Set((loaded.project.scopeRegistry?.scopes ?? []).map((s) => s.token));
1182
1455
  if (hostTokens.size === 0) return [];
1183
- const bundle = exportBundle4({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
1456
+ const bundle = exportBundle5({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
1184
1457
  const { written, proposals } = analyzeHostScopes(bundle, hostTokens);
1185
1458
  const declByRef = /* @__PURE__ */ new Map();
1186
1459
  for (const s of loaded.project.scopeRegistry?.scopes ?? []) {
@@ -1241,7 +1514,7 @@ function* sweep(loaded, options = {}, hooks = {}) {
1241
1514
  const reachedRuns = new Map(order.map((id) => [id, 0]));
1242
1515
  const dryRuns = /* @__PURE__ */ new Map();
1243
1516
  const termination = { ended: 0, capped: 0, stalled: 0, evalError: 0 };
1244
- const bundle = exportBundle4({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
1517
+ const bundle = exportBundle5({ project: loaded.project, scenes: loaded.scenes, locales: loaded.locales });
1245
1518
  const rng = mulberry32(seed);
1246
1519
  const hostTokens = new Set((loaded.project.scopeRegistry?.scopes ?? []).map((s) => s.token));
1247
1520
  const analysis = analyzeHostScopes(bundle, hostTokens);
@@ -1308,12 +1581,15 @@ function* sweep(loaded, options = {}, hooks = {}) {
1308
1581
  const m = meta.get(id);
1309
1582
  const reached = reachedRuns.get(id);
1310
1583
  let needsInput;
1584
+ let blockedBy;
1311
1585
  if (reached === 0) {
1312
1586
  const gates = [...analysis.gatesByBeat.get(id) ?? []].filter((r) => !analysis.written.has(r) && !drivenRefs.has(r));
1313
1587
  if (gates.length) {
1314
1588
  needsInput = gates;
1315
1589
  for (const g of gates) unwrittenInputs.add(g);
1316
1590
  }
1591
+ const blocked = blockedGates(id, analysis, drivenRefs, reachedRuns);
1592
+ if (blocked.length) blockedBy = blocked;
1317
1593
  }
1318
1594
  return {
1319
1595
  id,
@@ -1324,7 +1600,8 @@ function* sweep(loaded, options = {}, hooks = {}) {
1324
1600
  hits: hitCount.get(id),
1325
1601
  reachedRuns: reached,
1326
1602
  reachPct: executed ? reached / executed * 100 : 0,
1327
- ...needsInput ? { needsInput } : {}
1603
+ ...needsInput ? { needsInput } : {},
1604
+ ...blockedBy ? { blockedBy } : {}
1328
1605
  };
1329
1606
  });
1330
1607
  const neverHit = beats.filter((b) => b.reachedRuns === 0).length;
@@ -1387,9 +1664,16 @@ function renderCoverageText(report, sceneName = (id) => id) {
1387
1664
  out.push("");
1388
1665
  out.push(`${sceneName(scene)}${dead ? ` (${dead} never reached)` : ""}`);
1389
1666
  for (const b of beats) {
1390
- const mark = b.reachedRuns === 0 ? b.needsInput ? "? " : "\u203C " : " ";
1667
+ const mark = b.reachedRuns === 0 ? b.needsInput || b.blockedBy ? "? " : "\u203C " : " ";
1391
1668
  const label = b.character ? `${b.character}: ${clip(b.preview)}` : clip(b.preview || `(${b.kind})`);
1392
1669
  out.push(` ${mark}${b.reachPct.toFixed(0).padStart(3)}% ${String(b.hits).padStart(6)} ${label}`);
1670
+ for (const bg of b.blockedBy ?? []) {
1671
+ const names = bg.writers.map((w) => {
1672
+ const target = report.beats.find((x) => x.id === w);
1673
+ return target ? clip(target.preview || target.id, 28) : w;
1674
+ });
1675
+ out.push(` gated on ${bg.ref}, written only by: ${names.join(", ")} (never played either)`);
1676
+ }
1393
1677
  }
1394
1678
  }
1395
1679
  return out;
@@ -3469,6 +3753,7 @@ export {
3469
3753
  loadProjectLanding,
3470
3754
  poToCatalog,
3471
3755
  proposeCoverageDrivers,
3756
+ reachabilityIssues,
3472
3757
  renderCoverageText,
3473
3758
  renderPlay,
3474
3759
  renderReportText,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patterkit/ops",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The shared operations layer: every project operation (load, validate, export, format, play, ...) as a pure function, consumed by the patter CLI and Patterpad alike.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "@patterkit/compiler": "0.2.4",
31
31
  "@patterkit/core": "0.2.1",
32
32
  "@patterkit/model": "0.4.0",
33
- "@patterkit/runtime": "0.7.0",
33
+ "@patterkit/runtime": "0.7.1",
34
34
  "docx": "^9.7.1",
35
35
  "exceljs": "^4.4.0",
36
36
  "jszip": "^3.10.1",