@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 +24 -1
- package/dist/index.js +309 -24
- package/package.json +2 -2
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1158
|
-
if (
|
|
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
|
-
|
|
1169
|
-
scanEffects(node.
|
|
1170
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
33
|
+
"@patterkit/runtime": "0.7.1",
|
|
34
34
|
"docx": "^9.7.1",
|
|
35
35
|
"exceljs": "^4.4.0",
|
|
36
36
|
"jszip": "^3.10.1",
|