@plures/praxis 1.2.41 → 1.4.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/browser/{chunk-BBP2F7TT.js → chunk-MJK3IYTJ.js} +123 -5
- package/dist/browser/{chunk-FCEH7WMH.js → chunk-N63K4KWS.js} +1 -1
- package/dist/browser/{engine-65QDGCAN.js → engine-YIEGSX7U.js} +1 -1
- package/dist/browser/index.d.ts +2 -2
- package/dist/browser/index.js +10 -5
- package/dist/browser/integrations/svelte.d.ts +2 -2
- package/dist/browser/integrations/svelte.js +2 -2
- package/dist/browser/{reactive-engine.svelte-Cqd8Mod2.d.ts → reactive-engine.svelte-DjynI82A.d.ts} +83 -4
- package/dist/node/chunk-2IUFZBH3.js +87 -0
- package/dist/node/{chunk-WZ6B3LZ6.js → chunk-7CSWBDFL.js} +3 -56
- package/dist/node/{chunk-32YFEEML.js → chunk-7M3HV4XR.js} +4 -4
- package/dist/node/{chunk-PTH6MD6P.js → chunk-FWOXU4MM.js} +1 -1
- package/dist/node/{chunk-BBP2F7TT.js → chunk-KMJWAFZV.js} +128 -5
- package/dist/node/chunk-PGVSB6NR.js +59 -0
- package/dist/node/cli/index.cjs +1078 -211
- package/dist/node/cli/index.js +21 -2
- package/dist/node/cloud/index.d.cts +1 -1
- package/dist/node/cloud/index.d.ts +1 -1
- package/dist/node/{engine-7CXQV6RC.js → engine-FEN5IYZ5.js} +1 -1
- package/dist/node/index.cjs +1633 -59
- package/dist/node/index.d.cts +769 -5
- package/dist/node/index.d.ts +769 -5
- package/dist/node/index.js +1375 -45
- package/dist/node/integrations/svelte.cjs +123 -5
- package/dist/node/integrations/svelte.d.cts +3 -3
- package/dist/node/integrations/svelte.d.ts +3 -3
- package/dist/node/integrations/svelte.js +3 -3
- package/dist/node/{protocol-BocKczNv.d.ts → protocol-DcyGMmWY.d.cts} +7 -0
- package/dist/node/{protocol-BocKczNv.d.cts → protocol-DcyGMmWY.d.ts} +7 -0
- package/dist/node/{reactive-engine.svelte-CGe8SpVE.d.cts → reactive-engine.svelte-Cg0Yc2Hs.d.cts} +90 -6
- package/dist/node/{reactive-engine.svelte-D-xTDxT5.d.ts → reactive-engine.svelte-DekxqFu0.d.ts} +90 -6
- package/dist/node/{reverse-W7THPV45.js → reverse-YD3CWIGM.js} +3 -2
- package/dist/node/rules-4DAJ4Z4N.js +7 -0
- package/dist/node/server-SYZPDULV.js +361 -0
- package/dist/node/{validate-EN3M4FUR.js → validate-TQGVIG7G.js} +4 -3
- package/package.json +29 -3
- package/src/__tests__/engine-v2.test.ts +532 -0
- package/src/__tests__/expectations.test.ts +364 -0
- package/src/__tests__/factory.test.ts +426 -0
- package/src/__tests__/mcp-server.test.ts +310 -0
- package/src/__tests__/project.test.ts +396 -0
- package/src/cli/index.ts +28 -0
- package/src/core/completeness.ts +274 -0
- package/src/core/engine.ts +47 -5
- package/src/core/pluresdb/store.ts +9 -3
- package/src/core/protocol.ts +7 -0
- package/src/core/rule-result.ts +130 -0
- package/src/core/rules.ts +12 -5
- package/src/core/ui-rules.ts +340 -0
- package/src/expectations/expectations.ts +471 -0
- package/src/expectations/index.ts +29 -0
- package/src/expectations/types.ts +95 -0
- package/src/factory/factory.ts +634 -0
- package/src/factory/index.ts +27 -0
- package/src/factory/types.ts +64 -0
- package/src/index.ts +84 -0
- package/src/mcp/index.ts +33 -0
- package/src/mcp/server.ts +485 -0
- package/src/mcp/types.ts +161 -0
- package/src/project/index.ts +31 -0
- package/src/project/project.ts +423 -0
- package/src/project/types.ts +87 -0
- package/src/vite/completeness-plugin.ts +72 -0
- /package/dist/node/{chunk-R2PSBPKQ.js → chunk-TEMFJOIH.js} +0 -0
package/dist/node/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
auditCompleteness,
|
|
3
|
+
formatReport
|
|
4
|
+
} from "./chunk-2IUFZBH3.js";
|
|
1
5
|
import {
|
|
2
6
|
AcknowledgeContractGap,
|
|
3
7
|
BehaviorLedger,
|
|
@@ -21,12 +25,13 @@ import {
|
|
|
21
25
|
formatValidationReportJSON,
|
|
22
26
|
formatValidationReportSARIF,
|
|
23
27
|
validateContracts
|
|
24
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-FWOXU4MM.js";
|
|
29
|
+
import "./chunk-7CSWBDFL.js";
|
|
25
30
|
import {
|
|
26
31
|
defineContract,
|
|
27
32
|
getContract,
|
|
28
33
|
isContract
|
|
29
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-PGVSB6NR.js";
|
|
30
35
|
import {
|
|
31
36
|
InMemoryPraxisDB,
|
|
32
37
|
PluresDBPraxisAdapter,
|
|
@@ -50,10 +55,14 @@ import {
|
|
|
50
55
|
import {
|
|
51
56
|
ReactiveLogicEngine,
|
|
52
57
|
createReactiveEngine
|
|
53
|
-
} from "./chunk-
|
|
58
|
+
} from "./chunk-7M3HV4XR.js";
|
|
54
59
|
import {
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
LogicEngine,
|
|
61
|
+
PRAXIS_PROTOCOL_VERSION,
|
|
62
|
+
RuleResult,
|
|
63
|
+
createPraxisEngine,
|
|
64
|
+
fact
|
|
65
|
+
} from "./chunk-KMJWAFZV.js";
|
|
57
66
|
import {
|
|
58
67
|
TerminalAdapter,
|
|
59
68
|
createMockExecutor,
|
|
@@ -76,10 +85,8 @@ import {
|
|
|
76
85
|
validateSchema
|
|
77
86
|
} from "./chunk-UATVJBNV.js";
|
|
78
87
|
import {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
createPraxisEngine
|
|
82
|
-
} from "./chunk-BBP2F7TT.js";
|
|
88
|
+
PraxisRegistry
|
|
89
|
+
} from "./chunk-TEMFJOIH.js";
|
|
83
90
|
import "./chunk-QGM4M3NI.js";
|
|
84
91
|
|
|
85
92
|
// src/core/reactive-engine.ts
|
|
@@ -739,37 +746,37 @@ var PraxisDBStore = class {
|
|
|
739
746
|
* @param fact The fact to store
|
|
740
747
|
* @returns Promise that resolves when the fact is stored
|
|
741
748
|
*/
|
|
742
|
-
async storeFact(
|
|
743
|
-
const constraintResult = await this.checkConstraints([
|
|
749
|
+
async storeFact(fact2) {
|
|
750
|
+
const constraintResult = await this.checkConstraints([fact2]);
|
|
744
751
|
if (!constraintResult.valid) {
|
|
745
752
|
throw new Error(`Constraint violation: ${constraintResult.errors.join(", ")}`);
|
|
746
753
|
}
|
|
747
754
|
let before;
|
|
748
755
|
if (this.chronicle) {
|
|
749
|
-
const payload =
|
|
756
|
+
const payload = fact2.payload;
|
|
750
757
|
const id = payload?.id;
|
|
751
758
|
if (id) {
|
|
752
|
-
before = await this.getFact(
|
|
759
|
+
before = await this.getFact(fact2.tag, id);
|
|
753
760
|
}
|
|
754
761
|
}
|
|
755
|
-
await this.persistFact(
|
|
762
|
+
await this.persistFact(fact2);
|
|
756
763
|
if (this.chronicle) {
|
|
757
|
-
const payload =
|
|
764
|
+
const payload = fact2.payload;
|
|
758
765
|
const id = payload?.id ?? "";
|
|
759
766
|
const span = ChronicleContext.current;
|
|
760
767
|
try {
|
|
761
768
|
await this.chronicle.record({
|
|
762
|
-
path: getFactPath(
|
|
769
|
+
path: getFactPath(fact2.tag, id),
|
|
763
770
|
before,
|
|
764
|
-
after:
|
|
771
|
+
after: fact2,
|
|
765
772
|
cause: span?.spanId,
|
|
766
773
|
context: span?.contextId,
|
|
767
|
-
metadata: { factTag:
|
|
774
|
+
metadata: { factTag: fact2.tag, operation: "storeFact" }
|
|
768
775
|
});
|
|
769
776
|
} catch {
|
|
770
777
|
}
|
|
771
778
|
}
|
|
772
|
-
await this.triggerRules([
|
|
779
|
+
await this.triggerRules([fact2]);
|
|
773
780
|
}
|
|
774
781
|
/**
|
|
775
782
|
* Store multiple facts in PluresDB
|
|
@@ -781,28 +788,28 @@ var PraxisDBStore = class {
|
|
|
781
788
|
if (!constraintResult.valid) {
|
|
782
789
|
throw new Error(`Constraint violation: ${constraintResult.errors.join(", ")}`);
|
|
783
790
|
}
|
|
784
|
-
for (const
|
|
791
|
+
for (const fact2 of facts) {
|
|
785
792
|
let before;
|
|
786
793
|
if (this.chronicle) {
|
|
787
|
-
const payload =
|
|
794
|
+
const payload = fact2.payload;
|
|
788
795
|
const id = payload?.id;
|
|
789
796
|
if (id) {
|
|
790
|
-
before = await this.getFact(
|
|
797
|
+
before = await this.getFact(fact2.tag, id);
|
|
791
798
|
}
|
|
792
799
|
}
|
|
793
|
-
await this.persistFact(
|
|
800
|
+
await this.persistFact(fact2);
|
|
794
801
|
if (this.chronicle) {
|
|
795
|
-
const payload =
|
|
802
|
+
const payload = fact2.payload;
|
|
796
803
|
const id = payload?.id ?? "";
|
|
797
804
|
const span = ChronicleContext.current;
|
|
798
805
|
try {
|
|
799
806
|
await this.chronicle.record({
|
|
800
|
-
path: getFactPath(
|
|
807
|
+
path: getFactPath(fact2.tag, id),
|
|
801
808
|
before,
|
|
802
|
-
after:
|
|
809
|
+
after: fact2,
|
|
803
810
|
cause: span?.spanId,
|
|
804
811
|
context: span?.contextId,
|
|
805
|
-
metadata: { factTag:
|
|
812
|
+
metadata: { factTag: fact2.tag, operation: "storeFacts" }
|
|
806
813
|
});
|
|
807
814
|
} catch {
|
|
808
815
|
}
|
|
@@ -814,11 +821,11 @@ var PraxisDBStore = class {
|
|
|
814
821
|
* Internal method to persist a fact without constraint checking
|
|
815
822
|
* Used by both storeFact and derived fact storage
|
|
816
823
|
*/
|
|
817
|
-
async persistFact(
|
|
818
|
-
const payload =
|
|
824
|
+
async persistFact(fact2) {
|
|
825
|
+
const payload = fact2.payload;
|
|
819
826
|
const id = payload?.id ?? generateId();
|
|
820
|
-
const path = getFactPath(
|
|
821
|
-
await this.db.set(path,
|
|
827
|
+
const path = getFactPath(fact2.tag, id);
|
|
828
|
+
await this.db.set(path, fact2);
|
|
822
829
|
}
|
|
823
830
|
/**
|
|
824
831
|
* Get a fact by tag and id
|
|
@@ -955,8 +962,8 @@ var PraxisDBStore = class {
|
|
|
955
962
|
if (watchers) {
|
|
956
963
|
watchers.add(callback);
|
|
957
964
|
}
|
|
958
|
-
const unsubscribe = this.db.watch(path, (
|
|
959
|
-
callback([
|
|
965
|
+
const unsubscribe = this.db.watch(path, (fact2) => {
|
|
966
|
+
callback([fact2]);
|
|
960
967
|
});
|
|
961
968
|
this.subscriptions.push(unsubscribe);
|
|
962
969
|
return () => {
|
|
@@ -1014,13 +1021,18 @@ var PraxisDBStore = class {
|
|
|
1014
1021
|
const state = {
|
|
1015
1022
|
context: this.context,
|
|
1016
1023
|
facts: [],
|
|
1024
|
+
events,
|
|
1017
1025
|
meta: {}
|
|
1018
1026
|
};
|
|
1019
1027
|
const derivedFacts = [];
|
|
1020
1028
|
for (const rule of rules) {
|
|
1021
1029
|
try {
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1030
|
+
const result = rule.impl(state, events);
|
|
1031
|
+
if (Array.isArray(result)) {
|
|
1032
|
+
derivedFacts.push(...result);
|
|
1033
|
+
} else if (result && "kind" in result && result.kind === "emit") {
|
|
1034
|
+
derivedFacts.push(...result.facts);
|
|
1035
|
+
}
|
|
1024
1036
|
} catch (error) {
|
|
1025
1037
|
this.onRuleError(rule.id, error);
|
|
1026
1038
|
}
|
|
@@ -1028,19 +1040,19 @@ var PraxisDBStore = class {
|
|
|
1028
1040
|
if (derivedFacts.length > 0) {
|
|
1029
1041
|
const constraintResult = await this.checkConstraints(derivedFacts);
|
|
1030
1042
|
if (constraintResult.valid) {
|
|
1031
|
-
for (const
|
|
1032
|
-
await this.persistFact(
|
|
1043
|
+
for (const fact2 of derivedFacts) {
|
|
1044
|
+
await this.persistFact(fact2);
|
|
1033
1045
|
if (this.chronicle) {
|
|
1034
|
-
const payload =
|
|
1046
|
+
const payload = fact2.payload;
|
|
1035
1047
|
const id = payload?.id ?? "";
|
|
1036
1048
|
const span = ChronicleContext.current;
|
|
1037
1049
|
try {
|
|
1038
1050
|
await this.chronicle.record({
|
|
1039
|
-
path: getFactPath(
|
|
1040
|
-
after:
|
|
1051
|
+
path: getFactPath(fact2.tag, id),
|
|
1052
|
+
after: fact2,
|
|
1041
1053
|
cause: span?.spanId,
|
|
1042
1054
|
context: span?.contextId,
|
|
1043
|
-
metadata: { factTag:
|
|
1055
|
+
metadata: { factTag: fact2.tag, operation: "derivedFact" }
|
|
1044
1056
|
});
|
|
1045
1057
|
} catch {
|
|
1046
1058
|
}
|
|
@@ -1479,7 +1491,7 @@ async function createUnumAdapter(config) {
|
|
|
1479
1491
|
type: "event"
|
|
1480
1492
|
});
|
|
1481
1493
|
}
|
|
1482
|
-
async function broadcastFact(channelId,
|
|
1494
|
+
async function broadcastFact(channelId, fact2) {
|
|
1483
1495
|
const channel = channels.get(channelId);
|
|
1484
1496
|
if (!channel) {
|
|
1485
1497
|
throw new Error(`Not joined to channel ${channelId}`);
|
|
@@ -1487,7 +1499,7 @@ async function createUnumAdapter(config) {
|
|
|
1487
1499
|
await channel.publish({
|
|
1488
1500
|
id: generateId2(),
|
|
1489
1501
|
sender: currentIdentity || { id: "anonymous", createdAt: Date.now() },
|
|
1490
|
-
content:
|
|
1502
|
+
content: fact2,
|
|
1491
1503
|
type: "fact"
|
|
1492
1504
|
});
|
|
1493
1505
|
}
|
|
@@ -1768,7 +1780,7 @@ function generateTauriConfig(config) {
|
|
|
1768
1780
|
|
|
1769
1781
|
// src/integrations/unified.ts
|
|
1770
1782
|
async function createUnifiedApp(config) {
|
|
1771
|
-
const { createPraxisEngine: createPraxisEngine2 } = await import("./engine-
|
|
1783
|
+
const { createPraxisEngine: createPraxisEngine2 } = await import("./engine-FEN5IYZ5.js");
|
|
1772
1784
|
const { createInMemoryDB: createInMemoryDB2 } = await import("./adapter-75ISSMWD.js");
|
|
1773
1785
|
const db = config.db || createInMemoryDB2();
|
|
1774
1786
|
const pluresdb = createPluresDBAdapter({
|
|
@@ -1897,6 +1909,1290 @@ async function attachAllIntegrations(engine, registry, options = {}) {
|
|
|
1897
1909
|
}
|
|
1898
1910
|
};
|
|
1899
1911
|
}
|
|
1912
|
+
|
|
1913
|
+
// src/core/ui-rules.ts
|
|
1914
|
+
var loadingGateRule = {
|
|
1915
|
+
id: "ui/loading-gate",
|
|
1916
|
+
description: "Signals when the app is in a loading state",
|
|
1917
|
+
eventTypes: ["ui.state-change", "app.init"],
|
|
1918
|
+
impl: (state) => {
|
|
1919
|
+
const ctx = state.context;
|
|
1920
|
+
if (ctx.loading) {
|
|
1921
|
+
return RuleResult.emit([fact("ui.loading-gate", { active: true })]);
|
|
1922
|
+
}
|
|
1923
|
+
return RuleResult.retract(["ui.loading-gate"], "Not loading");
|
|
1924
|
+
},
|
|
1925
|
+
contract: {
|
|
1926
|
+
ruleId: "RULE_ID_PLACEHOLDER",
|
|
1927
|
+
behavior: "Emits ui.loading-gate when context.loading is true, retracts when false",
|
|
1928
|
+
examples: [
|
|
1929
|
+
{ given: "loading is true", when: "ui state changes", then: "ui.loading-gate emitted" },
|
|
1930
|
+
{ given: "loading is false", when: "ui state changes", then: "ui.loading-gate retracted" }
|
|
1931
|
+
],
|
|
1932
|
+
invariants: ["Loading gate must reflect context.loading exactly"]
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
var errorDisplayRule = {
|
|
1936
|
+
id: "ui/error-display",
|
|
1937
|
+
description: "Signals when an error should be displayed to the user",
|
|
1938
|
+
eventTypes: ["ui.state-change", "app.error"],
|
|
1939
|
+
impl: (state) => {
|
|
1940
|
+
const ctx = state.context;
|
|
1941
|
+
if (ctx.error) {
|
|
1942
|
+
return RuleResult.emit([fact("ui.error-display", { message: ctx.error, severity: "error" })]);
|
|
1943
|
+
}
|
|
1944
|
+
return RuleResult.retract(["ui.error-display"], "Error cleared");
|
|
1945
|
+
},
|
|
1946
|
+
contract: {
|
|
1947
|
+
ruleId: "RULE_ID_PLACEHOLDER",
|
|
1948
|
+
behavior: "Emits ui.error-display when context.error is non-null, retracts when cleared",
|
|
1949
|
+
examples: [
|
|
1950
|
+
{ given: "error is set", when: "ui state changes", then: "ui.error-display emitted with message" },
|
|
1951
|
+
{ given: "error is null", when: "ui state changes", then: "ui.error-display retracted" }
|
|
1952
|
+
],
|
|
1953
|
+
invariants: ["Error display must clear when error is null"]
|
|
1954
|
+
}
|
|
1955
|
+
};
|
|
1956
|
+
var offlineIndicatorRule = {
|
|
1957
|
+
id: "ui/offline-indicator",
|
|
1958
|
+
description: "Signals when the app is offline",
|
|
1959
|
+
eventTypes: ["ui.state-change", "network.change"],
|
|
1960
|
+
impl: (state) => {
|
|
1961
|
+
if (state.context.offline) {
|
|
1962
|
+
return RuleResult.emit([fact("ui.offline", { message: "You are offline. Changes will sync when reconnected." })]);
|
|
1963
|
+
}
|
|
1964
|
+
return RuleResult.retract(["ui.offline"], "Back online");
|
|
1965
|
+
},
|
|
1966
|
+
contract: {
|
|
1967
|
+
ruleId: "RULE_ID_PLACEHOLDER",
|
|
1968
|
+
behavior: "Emits ui.offline when context.offline is true, retracts when back online",
|
|
1969
|
+
examples: [
|
|
1970
|
+
{ given: "offline is true", when: "network changes", then: "ui.offline emitted" },
|
|
1971
|
+
{ given: "offline is false", when: "network changes", then: "ui.offline retracted" }
|
|
1972
|
+
],
|
|
1973
|
+
invariants: ["Offline indicator must match actual connectivity"]
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
var dirtyGuardRule = {
|
|
1977
|
+
id: "ui/dirty-guard",
|
|
1978
|
+
description: "Warns when there are unsaved changes",
|
|
1979
|
+
eventTypes: ["ui.state-change", "navigation.request"],
|
|
1980
|
+
impl: (state) => {
|
|
1981
|
+
if (state.context.dirty) {
|
|
1982
|
+
return RuleResult.emit([fact("ui.unsaved-warning", {
|
|
1983
|
+
message: "You have unsaved changes",
|
|
1984
|
+
blocking: true
|
|
1985
|
+
})]);
|
|
1986
|
+
}
|
|
1987
|
+
return RuleResult.retract(["ui.unsaved-warning"], "No unsaved changes");
|
|
1988
|
+
},
|
|
1989
|
+
contract: {
|
|
1990
|
+
ruleId: "RULE_ID_PLACEHOLDER",
|
|
1991
|
+
behavior: "Emits ui.unsaved-warning when context.dirty is true, retracts when saved",
|
|
1992
|
+
examples: [
|
|
1993
|
+
{ given: "dirty is true", when: "ui state changes", then: "ui.unsaved-warning emitted with blocking=true" },
|
|
1994
|
+
{ given: "dirty is false", when: "ui state changes", then: "ui.unsaved-warning retracted" }
|
|
1995
|
+
],
|
|
1996
|
+
invariants: ["Dirty guard must clear after save"]
|
|
1997
|
+
}
|
|
1998
|
+
};
|
|
1999
|
+
var initGateRule = {
|
|
2000
|
+
id: "ui/init-gate",
|
|
2001
|
+
description: "Signals whether the app has completed initialization",
|
|
2002
|
+
eventTypes: ["ui.state-change", "app.init"],
|
|
2003
|
+
impl: (state) => {
|
|
2004
|
+
if (!state.context.initialized) {
|
|
2005
|
+
return RuleResult.emit([fact("ui.init-pending", {
|
|
2006
|
+
message: "App is initializing..."
|
|
2007
|
+
})]);
|
|
2008
|
+
}
|
|
2009
|
+
return RuleResult.retract(["ui.init-pending"], "App initialized");
|
|
2010
|
+
},
|
|
2011
|
+
contract: {
|
|
2012
|
+
ruleId: "RULE_ID_PLACEHOLDER",
|
|
2013
|
+
behavior: "Emits ui.init-pending until context.initialized is true",
|
|
2014
|
+
examples: [
|
|
2015
|
+
{ given: "initialized is false", when: "app starts", then: "ui.init-pending emitted" },
|
|
2016
|
+
{ given: "initialized is true", when: "init completes", then: "ui.init-pending retracted" }
|
|
2017
|
+
],
|
|
2018
|
+
invariants: ["Init gate must clear exactly once, when initialization completes"]
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
var viewportRule = {
|
|
2022
|
+
id: "ui/viewport-class",
|
|
2023
|
+
description: "Classifies viewport size for responsive layout decisions",
|
|
2024
|
+
eventTypes: ["ui.state-change", "ui.resize"],
|
|
2025
|
+
impl: (state) => {
|
|
2026
|
+
const vp = state.context.viewport;
|
|
2027
|
+
if (!vp) return RuleResult.skip("No viewport data");
|
|
2028
|
+
return RuleResult.emit([fact("ui.viewport-class", {
|
|
2029
|
+
viewport: vp,
|
|
2030
|
+
compact: vp === "mobile",
|
|
2031
|
+
showSidebar: vp !== "mobile"
|
|
2032
|
+
})]);
|
|
2033
|
+
},
|
|
2034
|
+
contract: {
|
|
2035
|
+
ruleId: "RULE_ID_PLACEHOLDER",
|
|
2036
|
+
behavior: "Classifies viewport into responsive layout hints",
|
|
2037
|
+
examples: [
|
|
2038
|
+
{ given: "viewport is mobile", when: "resize event", then: "compact=true, showSidebar=false" },
|
|
2039
|
+
{ given: "viewport is desktop", when: "resize event", then: "compact=false, showSidebar=true" }
|
|
2040
|
+
],
|
|
2041
|
+
invariants: ["Viewport class must update on every resize event"]
|
|
2042
|
+
}
|
|
2043
|
+
};
|
|
2044
|
+
var noInteractionWhileLoadingConstraint = {
|
|
2045
|
+
id: "ui/no-interaction-while-loading",
|
|
2046
|
+
description: "Prevents data mutations while a load operation is in progress",
|
|
2047
|
+
impl: (state) => {
|
|
2048
|
+
if (state.context.loading) {
|
|
2049
|
+
return "Cannot perform action while data is loading";
|
|
2050
|
+
}
|
|
2051
|
+
return true;
|
|
2052
|
+
},
|
|
2053
|
+
contract: {
|
|
2054
|
+
ruleId: "RULE_ID_PLACEHOLDER",
|
|
2055
|
+
behavior: "Fails when context.loading is true",
|
|
2056
|
+
examples: [
|
|
2057
|
+
{ given: "loading is true", when: "action attempted", then: "violation" },
|
|
2058
|
+
{ given: "loading is false", when: "action attempted", then: "pass" }
|
|
2059
|
+
],
|
|
2060
|
+
invariants: ["Must always fail during loading"]
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
2063
|
+
var mustBeInitializedConstraint = {
|
|
2064
|
+
id: "ui/must-be-initialized",
|
|
2065
|
+
description: "Requires app initialization before user interactions",
|
|
2066
|
+
impl: (state) => {
|
|
2067
|
+
if (!state.context.initialized) {
|
|
2068
|
+
return "App must be initialized before performing this action";
|
|
2069
|
+
}
|
|
2070
|
+
return true;
|
|
2071
|
+
},
|
|
2072
|
+
contract: {
|
|
2073
|
+
ruleId: "RULE_ID_PLACEHOLDER",
|
|
2074
|
+
behavior: "Fails when context.initialized is false",
|
|
2075
|
+
examples: [
|
|
2076
|
+
{ given: "initialized is false", when: "action attempted", then: "violation" },
|
|
2077
|
+
{ given: "initialized is true", when: "action attempted", then: "pass" }
|
|
2078
|
+
],
|
|
2079
|
+
invariants: ["Must always fail before init completes"]
|
|
2080
|
+
}
|
|
2081
|
+
};
|
|
2082
|
+
var uiModule = {
|
|
2083
|
+
rules: [
|
|
2084
|
+
loadingGateRule,
|
|
2085
|
+
errorDisplayRule,
|
|
2086
|
+
offlineIndicatorRule,
|
|
2087
|
+
dirtyGuardRule,
|
|
2088
|
+
initGateRule,
|
|
2089
|
+
viewportRule
|
|
2090
|
+
],
|
|
2091
|
+
constraints: [
|
|
2092
|
+
noInteractionWhileLoadingConstraint,
|
|
2093
|
+
mustBeInitializedConstraint
|
|
2094
|
+
],
|
|
2095
|
+
meta: {
|
|
2096
|
+
name: "praxis-ui",
|
|
2097
|
+
version: "1.0.0",
|
|
2098
|
+
description: "Predefined UI rules and constraints \u2014 separate from business logic"
|
|
2099
|
+
}
|
|
2100
|
+
};
|
|
2101
|
+
function createUIModule(options) {
|
|
2102
|
+
const allRules = uiModule.rules;
|
|
2103
|
+
const allConstraints = uiModule.constraints;
|
|
2104
|
+
const selectedRules = options.rules ? allRules.filter((r) => options.rules.includes(r.id)) : allRules;
|
|
2105
|
+
const selectedConstraints = options.constraints ? allConstraints.filter((c) => options.constraints.includes(c.id)) : allConstraints;
|
|
2106
|
+
return {
|
|
2107
|
+
rules: [...selectedRules, ...options.extraRules ?? []],
|
|
2108
|
+
constraints: [...selectedConstraints, ...options.extraConstraints ?? []],
|
|
2109
|
+
meta: { ...uiModule.meta, customized: true }
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
function uiStateChanged(changes) {
|
|
2113
|
+
return { tag: "ui.state-change", payload: changes ?? {} };
|
|
2114
|
+
}
|
|
2115
|
+
function navigationRequest(to) {
|
|
2116
|
+
return { tag: "navigation.request", payload: { to } };
|
|
2117
|
+
}
|
|
2118
|
+
function resizeEvent(width, height) {
|
|
2119
|
+
return { tag: "ui.resize", payload: { width, height } };
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// src/expectations/expectations.ts
|
|
2123
|
+
var Expectation = class {
|
|
2124
|
+
name;
|
|
2125
|
+
_conditions = [];
|
|
2126
|
+
constructor(name) {
|
|
2127
|
+
this.name = name;
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Declare that this behavior should ONLY occur when a condition is true.
|
|
2131
|
+
* If the condition is false, the behavior should NOT occur.
|
|
2132
|
+
*/
|
|
2133
|
+
onlyWhen(condition) {
|
|
2134
|
+
this._conditions.push({ description: condition, type: "onlyWhen" });
|
|
2135
|
+
return this;
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Declare that this behavior should NEVER occur under a given condition.
|
|
2139
|
+
*/
|
|
2140
|
+
never(condition) {
|
|
2141
|
+
this._conditions.push({ description: condition, type: "never" });
|
|
2142
|
+
return this;
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Declare that this behavior should ALWAYS have a certain property.
|
|
2146
|
+
*/
|
|
2147
|
+
always(condition) {
|
|
2148
|
+
this._conditions.push({ description: condition, type: "always" });
|
|
2149
|
+
return this;
|
|
2150
|
+
}
|
|
2151
|
+
/** Get all declared conditions. */
|
|
2152
|
+
get conditions() {
|
|
2153
|
+
return this._conditions;
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
var ExpectationSet = class {
|
|
2157
|
+
name;
|
|
2158
|
+
description;
|
|
2159
|
+
_expectations = [];
|
|
2160
|
+
constructor(options) {
|
|
2161
|
+
this.name = options.name;
|
|
2162
|
+
this.description = options.description ?? "";
|
|
2163
|
+
}
|
|
2164
|
+
/** Add an expectation to the set. */
|
|
2165
|
+
add(expectation) {
|
|
2166
|
+
this._expectations.push(expectation);
|
|
2167
|
+
return this;
|
|
2168
|
+
}
|
|
2169
|
+
/** Get all expectations in this set. */
|
|
2170
|
+
get expectations() {
|
|
2171
|
+
return this._expectations;
|
|
2172
|
+
}
|
|
2173
|
+
/** Number of expectations. */
|
|
2174
|
+
get size() {
|
|
2175
|
+
return this._expectations.length;
|
|
2176
|
+
}
|
|
2177
|
+
};
|
|
2178
|
+
function expectBehavior(name) {
|
|
2179
|
+
return new Expectation(name);
|
|
2180
|
+
}
|
|
2181
|
+
function verify(registry, expectations) {
|
|
2182
|
+
const rules = registry.getAllRules();
|
|
2183
|
+
const constraints = registry.getAllConstraints();
|
|
2184
|
+
const ruleIds = registry.getRuleIds();
|
|
2185
|
+
const constraintIds = registry.getConstraintIds();
|
|
2186
|
+
const allDescriptors = [...rules, ...constraints];
|
|
2187
|
+
const expectationResults = [];
|
|
2188
|
+
for (const exp of expectations.expectations) {
|
|
2189
|
+
const result = verifyExpectation(exp, allDescriptors, ruleIds, constraintIds);
|
|
2190
|
+
expectationResults.push(result);
|
|
2191
|
+
}
|
|
2192
|
+
const satisfied = expectationResults.filter((r) => r.status === "satisfied").length;
|
|
2193
|
+
const violated = expectationResults.filter((r) => r.status === "violated").length;
|
|
2194
|
+
const partial = expectationResults.filter((r) => r.status === "partial").length;
|
|
2195
|
+
const allEdgeCases = expectationResults.flatMap((r) => r.edgeCases);
|
|
2196
|
+
const allMitigations = expectationResults.flatMap((r) => r.mitigations);
|
|
2197
|
+
return {
|
|
2198
|
+
setName: expectations.name,
|
|
2199
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2200
|
+
status: violated > 0 ? "violated" : partial > 0 ? "partial" : "satisfied",
|
|
2201
|
+
expectations: expectationResults,
|
|
2202
|
+
summary: {
|
|
2203
|
+
total: expectationResults.length,
|
|
2204
|
+
satisfied,
|
|
2205
|
+
violated,
|
|
2206
|
+
partial
|
|
2207
|
+
},
|
|
2208
|
+
allEdgeCases,
|
|
2209
|
+
allMitigations
|
|
2210
|
+
};
|
|
2211
|
+
}
|
|
2212
|
+
function verifyExpectation(expectation, descriptors, ruleIds, _constraintIds) {
|
|
2213
|
+
const conditionResults = [];
|
|
2214
|
+
const edgeCases = [];
|
|
2215
|
+
const mitigations = [];
|
|
2216
|
+
const related = findRelatedDescriptors(expectation.name, descriptors);
|
|
2217
|
+
for (const condition of expectation.conditions) {
|
|
2218
|
+
const result = verifyCondition(condition, expectation.name, related, ruleIds);
|
|
2219
|
+
conditionResults.push(result);
|
|
2220
|
+
if (result.status === "unverifiable") {
|
|
2221
|
+
edgeCases.push(`Cannot verify "${condition.description}" for "${expectation.name}" \u2014 no covering rule/contract found`);
|
|
2222
|
+
mitigations.push(`Add a rule or constraint that explicitly covers: ${condition.description}`);
|
|
2223
|
+
} else if (result.status === "violated") {
|
|
2224
|
+
edgeCases.push(`"${expectation.name}" may fire incorrectly: ${condition.description}`);
|
|
2225
|
+
mitigations.push(`Review rule logic for "${expectation.name}" regarding: ${condition.description}`);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
const satisfiedCount = conditionResults.filter((r) => r.status === "satisfied").length;
|
|
2229
|
+
const violatedCount = conditionResults.filter((r) => r.status === "violated").length;
|
|
2230
|
+
const total = conditionResults.length;
|
|
2231
|
+
let status;
|
|
2232
|
+
if (total === 0) {
|
|
2233
|
+
status = "satisfied";
|
|
2234
|
+
} else if (violatedCount > 0) {
|
|
2235
|
+
status = "violated";
|
|
2236
|
+
} else if (satisfiedCount === total) {
|
|
2237
|
+
status = "satisfied";
|
|
2238
|
+
} else {
|
|
2239
|
+
status = "partial";
|
|
2240
|
+
}
|
|
2241
|
+
return {
|
|
2242
|
+
name: expectation.name,
|
|
2243
|
+
status,
|
|
2244
|
+
conditions: conditionResults,
|
|
2245
|
+
edgeCases,
|
|
2246
|
+
mitigations
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
function textOverlaps(a, b) {
|
|
2250
|
+
const stopWords = /* @__PURE__ */ new Set([
|
|
2251
|
+
"the",
|
|
2252
|
+
"a",
|
|
2253
|
+
"an",
|
|
2254
|
+
"is",
|
|
2255
|
+
"are",
|
|
2256
|
+
"was",
|
|
2257
|
+
"were",
|
|
2258
|
+
"be",
|
|
2259
|
+
"been",
|
|
2260
|
+
"being",
|
|
2261
|
+
"have",
|
|
2262
|
+
"has",
|
|
2263
|
+
"had",
|
|
2264
|
+
"do",
|
|
2265
|
+
"does",
|
|
2266
|
+
"did",
|
|
2267
|
+
"will",
|
|
2268
|
+
"would",
|
|
2269
|
+
"could",
|
|
2270
|
+
"should",
|
|
2271
|
+
"may",
|
|
2272
|
+
"might",
|
|
2273
|
+
"shall",
|
|
2274
|
+
"can",
|
|
2275
|
+
"to",
|
|
2276
|
+
"of",
|
|
2277
|
+
"in",
|
|
2278
|
+
"for",
|
|
2279
|
+
"on",
|
|
2280
|
+
"with",
|
|
2281
|
+
"at",
|
|
2282
|
+
"by",
|
|
2283
|
+
"from",
|
|
2284
|
+
"as",
|
|
2285
|
+
"into",
|
|
2286
|
+
"through",
|
|
2287
|
+
"during",
|
|
2288
|
+
"before",
|
|
2289
|
+
"after",
|
|
2290
|
+
"when",
|
|
2291
|
+
"that",
|
|
2292
|
+
"this",
|
|
2293
|
+
"it",
|
|
2294
|
+
"its",
|
|
2295
|
+
"and",
|
|
2296
|
+
"or",
|
|
2297
|
+
"but",
|
|
2298
|
+
"not",
|
|
2299
|
+
"no",
|
|
2300
|
+
"if",
|
|
2301
|
+
"then",
|
|
2302
|
+
"than",
|
|
2303
|
+
"so",
|
|
2304
|
+
"up",
|
|
2305
|
+
"out",
|
|
2306
|
+
"about",
|
|
2307
|
+
"just",
|
|
2308
|
+
"must"
|
|
2309
|
+
]);
|
|
2310
|
+
const wordsA = a.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
|
|
2311
|
+
const wordsB = b.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 2 && !stopWords.has(w));
|
|
2312
|
+
if (wordsA.length === 0 || wordsB.length === 0) return false;
|
|
2313
|
+
const matches = wordsA.filter(
|
|
2314
|
+
(wa) => wordsB.some((wb) => wa.startsWith(wb.slice(0, 4)) || wb.startsWith(wa.slice(0, 4)))
|
|
2315
|
+
);
|
|
2316
|
+
const minWords = Math.min(wordsA.length, wordsB.length);
|
|
2317
|
+
return matches.length >= Math.max(1, Math.ceil(minWords * 0.5));
|
|
2318
|
+
}
|
|
2319
|
+
function verifyCondition(condition, expectationName, relatedDescriptors, _ruleIds) {
|
|
2320
|
+
if (relatedDescriptors.length === 0) {
|
|
2321
|
+
return {
|
|
2322
|
+
condition,
|
|
2323
|
+
status: "unverifiable",
|
|
2324
|
+
explanation: `No rules or constraints found related to "${expectationName}"`,
|
|
2325
|
+
relatedRules: []
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
const relatedIds = relatedDescriptors.map((d) => d.id);
|
|
2329
|
+
const condLower = condition.description.toLowerCase();
|
|
2330
|
+
const matches = (target) => target.toLowerCase().includes(condLower) || condLower.includes(target.toLowerCase()) || textOverlaps(condLower, target);
|
|
2331
|
+
switch (condition.type) {
|
|
2332
|
+
case "onlyWhen": {
|
|
2333
|
+
const coveringRule = relatedDescriptors.find(
|
|
2334
|
+
(d) => d.contract?.examples.some(
|
|
2335
|
+
(ex) => matches(ex.given) || matches(ex.when)
|
|
2336
|
+
) || d.contract?.invariants.some((inv) => matches(inv))
|
|
2337
|
+
);
|
|
2338
|
+
if (coveringRule) {
|
|
2339
|
+
return {
|
|
2340
|
+
condition,
|
|
2341
|
+
status: "satisfied",
|
|
2342
|
+
explanation: `Rule "${coveringRule.id}" contract covers precondition: ${condition.description}`,
|
|
2343
|
+
relatedRules: relatedIds
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
const descMatch = relatedDescriptors.find((d) => matches(d.description));
|
|
2347
|
+
if (descMatch) {
|
|
2348
|
+
return {
|
|
2349
|
+
condition,
|
|
2350
|
+
status: "satisfied",
|
|
2351
|
+
explanation: `Rule "${descMatch.id}" description addresses: ${condition.description}`,
|
|
2352
|
+
relatedRules: relatedIds
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
return {
|
|
2356
|
+
condition,
|
|
2357
|
+
status: "unverifiable",
|
|
2358
|
+
explanation: `No rule contract explicitly covers the precondition: ${condition.description}`,
|
|
2359
|
+
relatedRules: relatedIds
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
case "never": {
|
|
2363
|
+
const preventingDescriptor = relatedDescriptors.find(
|
|
2364
|
+
(d) => d.contract?.invariants.some((inv) => matches(inv)) || d.contract?.examples.some(
|
|
2365
|
+
(ex) => (matches(ex.given) || matches(ex.when)) && (ex.then.toLowerCase().includes("retract") || ex.then.toLowerCase().includes("fail") || ex.then.toLowerCase().includes("violation") || ex.then.toLowerCase().includes("skip") || ex.then.toLowerCase().includes("block") || ex.then.toLowerCase().includes("no "))
|
|
2366
|
+
) || d.contract?.behavior.toLowerCase().includes("block") || d.contract?.behavior.toLowerCase().includes("prevent")
|
|
2367
|
+
);
|
|
2368
|
+
if (preventingDescriptor) {
|
|
2369
|
+
return {
|
|
2370
|
+
condition,
|
|
2371
|
+
status: "satisfied",
|
|
2372
|
+
explanation: `Constraint/rule "${preventingDescriptor.id}" prevents: ${condition.description}`,
|
|
2373
|
+
relatedRules: relatedIds
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
return {
|
|
2377
|
+
condition,
|
|
2378
|
+
status: "unverifiable",
|
|
2379
|
+
explanation: `No rule or constraint explicitly prevents: ${condition.description}`,
|
|
2380
|
+
relatedRules: relatedIds
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
case "always": {
|
|
2384
|
+
const guaranteeing = relatedDescriptors.find(
|
|
2385
|
+
(d) => d.contract?.invariants.some((inv) => matches(inv)) || d.contract?.behavior && matches(d.contract.behavior)
|
|
2386
|
+
);
|
|
2387
|
+
if (guaranteeing) {
|
|
2388
|
+
return {
|
|
2389
|
+
condition,
|
|
2390
|
+
status: "satisfied",
|
|
2391
|
+
explanation: `Rule "${guaranteeing.id}" guarantees: ${condition.description}`,
|
|
2392
|
+
relatedRules: relatedIds
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
const exampleMatch = relatedDescriptors.find(
|
|
2396
|
+
(d) => d.contract?.examples.some((ex) => matches(ex.then))
|
|
2397
|
+
);
|
|
2398
|
+
if (exampleMatch) {
|
|
2399
|
+
return {
|
|
2400
|
+
condition,
|
|
2401
|
+
status: "satisfied",
|
|
2402
|
+
explanation: `Rule "${exampleMatch.id}" example demonstrates: ${condition.description}`,
|
|
2403
|
+
relatedRules: relatedIds
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
return {
|
|
2407
|
+
condition,
|
|
2408
|
+
status: "unverifiable",
|
|
2409
|
+
explanation: `No rule contract guarantees: ${condition.description}`,
|
|
2410
|
+
relatedRules: relatedIds
|
|
2411
|
+
};
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
function findRelatedDescriptors(expectationName, descriptors) {
|
|
2416
|
+
const nameLower = expectationName.toLowerCase();
|
|
2417
|
+
const nameParts = nameLower.split(/[-_./\s]+/);
|
|
2418
|
+
return descriptors.filter((d) => {
|
|
2419
|
+
const idLower = d.id.toLowerCase();
|
|
2420
|
+
const descLower = d.description.toLowerCase();
|
|
2421
|
+
const behaviorLower = d.contract?.behavior?.toLowerCase() ?? "";
|
|
2422
|
+
if (idLower.includes(nameLower) || nameLower.includes(idLower)) return true;
|
|
2423
|
+
if (descLower.includes(nameLower)) return true;
|
|
2424
|
+
if (behaviorLower.includes(nameLower)) return true;
|
|
2425
|
+
const minParts = Math.min(2, nameParts.length);
|
|
2426
|
+
const matchingParts = nameParts.filter(
|
|
2427
|
+
(part) => part.length > 2 && (idLower.includes(part) || descLower.includes(part) || behaviorLower.includes(part))
|
|
2428
|
+
);
|
|
2429
|
+
if (matchingParts.length >= minParts) return true;
|
|
2430
|
+
if (d.eventTypes) {
|
|
2431
|
+
const eventStr = Array.isArray(d.eventTypes) ? d.eventTypes.join(" ") : d.eventTypes;
|
|
2432
|
+
if (eventStr.toLowerCase().includes(nameLower)) return true;
|
|
2433
|
+
}
|
|
2434
|
+
return false;
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
function formatVerificationReport(report) {
|
|
2438
|
+
const lines = [];
|
|
2439
|
+
const icon = report.status === "satisfied" ? "\u2705" : report.status === "partial" ? "\u{1F7E1}" : "\u{1F534}";
|
|
2440
|
+
lines.push(`${icon} Expectations: ${report.setName} \u2014 ${report.status.toUpperCase()}`);
|
|
2441
|
+
lines.push(` ${report.summary.satisfied}/${report.summary.total} satisfied, ${report.summary.violated} violated, ${report.summary.partial} partial`);
|
|
2442
|
+
lines.push("");
|
|
2443
|
+
for (const exp of report.expectations) {
|
|
2444
|
+
const expIcon = exp.status === "satisfied" ? "\u2705" : exp.status === "partial" ? "\u{1F7E1}" : "\u{1F534}";
|
|
2445
|
+
lines.push(`${expIcon} ${exp.name}`);
|
|
2446
|
+
for (const cond of exp.conditions) {
|
|
2447
|
+
const condIcon = cond.status === "satisfied" ? " \u2713" : cond.status === "violated" ? " \u2717" : " ?";
|
|
2448
|
+
lines.push(`${condIcon} [${cond.condition.type}] ${cond.condition.description}`);
|
|
2449
|
+
lines.push(` ${cond.explanation}`);
|
|
2450
|
+
}
|
|
2451
|
+
if (exp.edgeCases.length > 0) {
|
|
2452
|
+
lines.push(" Edge cases:");
|
|
2453
|
+
for (const ec of exp.edgeCases) {
|
|
2454
|
+
lines.push(` \u26A0\uFE0F ${ec}`);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
lines.push("");
|
|
2458
|
+
}
|
|
2459
|
+
if (report.allMitigations.length > 0) {
|
|
2460
|
+
lines.push("Suggested mitigations:");
|
|
2461
|
+
for (const m of report.allMitigations) {
|
|
2462
|
+
lines.push(` \u{1F4A1} ${m}`);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
return lines.join("\n");
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// src/factory/factory.ts
|
|
2469
|
+
var SANITIZE_PATTERNS = {
|
|
2470
|
+
"sql-injection": /('|"|;|--|\/\*|\*\/|xp_|exec\s|union\s+select|drop\s+table|insert\s+into|delete\s+from)/i,
|
|
2471
|
+
"xss": /(<script|javascript:|on\w+\s*=|<iframe|<object|<embed|<img[^>]+onerror)/i,
|
|
2472
|
+
"path-traversal": /(\.\.[/\\]|~\/|\/etc\/|\/proc\/)/i,
|
|
2473
|
+
"command-injection": /([;&|`$]|\$\(|>\s*\/)/i
|
|
2474
|
+
};
|
|
2475
|
+
function inputRules(config = {}) {
|
|
2476
|
+
const {
|
|
2477
|
+
sanitize = [],
|
|
2478
|
+
maxLength = 0,
|
|
2479
|
+
required = false,
|
|
2480
|
+
fieldName = "input"
|
|
2481
|
+
} = config;
|
|
2482
|
+
const rules = [];
|
|
2483
|
+
const constraints = [];
|
|
2484
|
+
if (sanitize.length > 0) {
|
|
2485
|
+
rules.push({
|
|
2486
|
+
id: `factory/input.sanitize-${fieldName}`,
|
|
2487
|
+
description: `Validates ${fieldName} against ${sanitize.join(", ")} patterns`,
|
|
2488
|
+
eventTypes: [`${fieldName}.submit`, `${fieldName}.change`],
|
|
2489
|
+
contract: {
|
|
2490
|
+
ruleId: `factory/input.sanitize-${fieldName}`,
|
|
2491
|
+
behavior: `Checks ${fieldName} for dangerous patterns: ${sanitize.join(", ")}`,
|
|
2492
|
+
examples: [
|
|
2493
|
+
{ given: `${fieldName} contains safe text`, when: "input submitted", then: "input.valid emitted" },
|
|
2494
|
+
{ given: `${fieldName} contains <script> tag`, when: "input submitted", then: "input.violation emitted" }
|
|
2495
|
+
],
|
|
2496
|
+
invariants: [
|
|
2497
|
+
`Dangerous ${fieldName} patterns must never pass validation`,
|
|
2498
|
+
"All violations must include the violation type"
|
|
2499
|
+
]
|
|
2500
|
+
},
|
|
2501
|
+
impl: (state, events) => {
|
|
2502
|
+
const inputEvent = events.find(
|
|
2503
|
+
(e) => e.tag === `${fieldName}.submit` || e.tag === `${fieldName}.change`
|
|
2504
|
+
);
|
|
2505
|
+
if (!inputEvent) return RuleResult.skip("No input event");
|
|
2506
|
+
const value = inputEvent.payload?.value ?? state.context.input?.value ?? "";
|
|
2507
|
+
const violations = [];
|
|
2508
|
+
for (const type of sanitize) {
|
|
2509
|
+
const pattern = SANITIZE_PATTERNS[type];
|
|
2510
|
+
if (pattern && pattern.test(value)) {
|
|
2511
|
+
violations.push(type);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
if (violations.length > 0) {
|
|
2515
|
+
return RuleResult.emit([
|
|
2516
|
+
fact(`${fieldName}.violation`, {
|
|
2517
|
+
field: fieldName,
|
|
2518
|
+
violations,
|
|
2519
|
+
message: `Input failed sanitization: ${violations.join(", ")}`
|
|
2520
|
+
})
|
|
2521
|
+
]);
|
|
2522
|
+
}
|
|
2523
|
+
return RuleResult.emit([
|
|
2524
|
+
fact(`${fieldName}.valid`, { field: fieldName, sanitized: true })
|
|
2525
|
+
]);
|
|
2526
|
+
}
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
if (maxLength > 0) {
|
|
2530
|
+
constraints.push({
|
|
2531
|
+
id: `factory/input.max-length-${fieldName}`,
|
|
2532
|
+
description: `${fieldName} must not exceed ${maxLength} characters`,
|
|
2533
|
+
contract: {
|
|
2534
|
+
ruleId: `factory/input.max-length-${fieldName}`,
|
|
2535
|
+
behavior: `Enforces max length of ${maxLength} for ${fieldName}`,
|
|
2536
|
+
examples: [
|
|
2537
|
+
{ given: `${fieldName} is 10 chars`, when: `maxLength is ${maxLength}`, then: maxLength >= 10 ? "passes" : "violation" }
|
|
2538
|
+
],
|
|
2539
|
+
invariants: [`${fieldName} length must never exceed ${maxLength}`]
|
|
2540
|
+
},
|
|
2541
|
+
impl: (state) => {
|
|
2542
|
+
const value = state.context.input?.value ?? "";
|
|
2543
|
+
if (value.length > maxLength) {
|
|
2544
|
+
return `${fieldName} exceeds maximum length of ${maxLength} (got ${value.length})`;
|
|
2545
|
+
}
|
|
2546
|
+
return true;
|
|
2547
|
+
}
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
if (required) {
|
|
2551
|
+
constraints.push({
|
|
2552
|
+
id: `factory/input.required-${fieldName}`,
|
|
2553
|
+
description: `${fieldName} is required and must not be empty`,
|
|
2554
|
+
contract: {
|
|
2555
|
+
ruleId: `factory/input.required-${fieldName}`,
|
|
2556
|
+
behavior: `Enforces that ${fieldName} is non-empty`,
|
|
2557
|
+
examples: [
|
|
2558
|
+
{ given: `${fieldName} is "hello"`, when: "checked", then: "passes" },
|
|
2559
|
+
{ given: `${fieldName} is empty`, when: "checked", then: "violation" }
|
|
2560
|
+
],
|
|
2561
|
+
invariants: [`${fieldName} must never be empty when required`]
|
|
2562
|
+
},
|
|
2563
|
+
impl: (state) => {
|
|
2564
|
+
const value = state.context.input?.value ?? "";
|
|
2565
|
+
if (value.trim().length === 0) {
|
|
2566
|
+
return `${fieldName} is required but empty`;
|
|
2567
|
+
}
|
|
2568
|
+
return true;
|
|
2569
|
+
}
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
return { rules, constraints };
|
|
2573
|
+
}
|
|
2574
|
+
function toastRules(config = {}) {
|
|
2575
|
+
const {
|
|
2576
|
+
requireDiff = false,
|
|
2577
|
+
autoDismissMs = 0,
|
|
2578
|
+
deduplicate = false
|
|
2579
|
+
} = config;
|
|
2580
|
+
const rules = [];
|
|
2581
|
+
const constraints = [];
|
|
2582
|
+
rules.push({
|
|
2583
|
+
id: "factory/toast.show",
|
|
2584
|
+
description: "Emits toast notification with content and config",
|
|
2585
|
+
eventTypes: ["toast.request"],
|
|
2586
|
+
contract: {
|
|
2587
|
+
ruleId: "factory/toast.show",
|
|
2588
|
+
behavior: "Shows toast when requested, respecting diff requirement and auto-dismiss",
|
|
2589
|
+
examples: [
|
|
2590
|
+
{ given: "toast requested with message", when: "toast.request fires", then: "toast.show emitted" },
|
|
2591
|
+
...requireDiff ? [{ given: "no diff present", when: "toast.request fires", then: "toast skipped" }] : []
|
|
2592
|
+
],
|
|
2593
|
+
invariants: [
|
|
2594
|
+
"Toast message must be non-empty",
|
|
2595
|
+
...requireDiff ? ["Toast must not appear when diff is empty"] : []
|
|
2596
|
+
]
|
|
2597
|
+
},
|
|
2598
|
+
impl: (state, events) => {
|
|
2599
|
+
const toastEvent = events.find((e) => e.tag === "toast.request");
|
|
2600
|
+
if (!toastEvent) return RuleResult.skip("No toast request");
|
|
2601
|
+
const payload = toastEvent.payload;
|
|
2602
|
+
const message = payload.message ?? "";
|
|
2603
|
+
if (!message) return RuleResult.skip("Empty toast message");
|
|
2604
|
+
if (requireDiff) {
|
|
2605
|
+
const diff = state.context.diff;
|
|
2606
|
+
if (!diff || Object.keys(diff).length === 0) {
|
|
2607
|
+
return RuleResult.skip("No diff \u2014 toast suppressed");
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
return RuleResult.emit([
|
|
2611
|
+
fact("toast.show", {
|
|
2612
|
+
message,
|
|
2613
|
+
type: payload.type ?? "info",
|
|
2614
|
+
autoDismissMs: autoDismissMs > 0 ? autoDismissMs : void 0,
|
|
2615
|
+
timestamp: Date.now()
|
|
2616
|
+
})
|
|
2617
|
+
]);
|
|
2618
|
+
}
|
|
2619
|
+
});
|
|
2620
|
+
if (deduplicate) {
|
|
2621
|
+
constraints.push({
|
|
2622
|
+
id: "factory/toast.no-duplicates",
|
|
2623
|
+
description: "Prevents duplicate toast messages",
|
|
2624
|
+
contract: {
|
|
2625
|
+
ruleId: "factory/toast.no-duplicates",
|
|
2626
|
+
behavior: "Rejects toast if identical message is already showing",
|
|
2627
|
+
examples: [
|
|
2628
|
+
{ given: "same toast already visible", when: "duplicate toast requested", then: "violation" }
|
|
2629
|
+
],
|
|
2630
|
+
invariants: ["No two toasts may have the same message simultaneously"]
|
|
2631
|
+
},
|
|
2632
|
+
impl: (state) => {
|
|
2633
|
+
const toasts = state.context.toasts ?? [];
|
|
2634
|
+
const messages = toasts.map((t) => t.message);
|
|
2635
|
+
const uniqueMessages = new Set(messages);
|
|
2636
|
+
if (uniqueMessages.size < messages.length) {
|
|
2637
|
+
return "Duplicate toast detected";
|
|
2638
|
+
}
|
|
2639
|
+
return true;
|
|
2640
|
+
}
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
return { rules, constraints };
|
|
2644
|
+
}
|
|
2645
|
+
function formRules(config = {}) {
|
|
2646
|
+
const {
|
|
2647
|
+
validateOnBlur = false,
|
|
2648
|
+
submitGate = false,
|
|
2649
|
+
formName = "form"
|
|
2650
|
+
} = config;
|
|
2651
|
+
const rules = [];
|
|
2652
|
+
const constraints = [];
|
|
2653
|
+
if (validateOnBlur) {
|
|
2654
|
+
rules.push({
|
|
2655
|
+
id: `factory/${formName}.validate-on-blur`,
|
|
2656
|
+
description: `Triggers field validation when a ${formName} field loses focus`,
|
|
2657
|
+
eventTypes: [`${formName}.blur`],
|
|
2658
|
+
contract: {
|
|
2659
|
+
ruleId: `factory/${formName}.validate-on-blur`,
|
|
2660
|
+
behavior: `Validates the blurred field and emits validation result`,
|
|
2661
|
+
examples: [
|
|
2662
|
+
{ given: `${formName} field has value`, when: "field loses focus", then: "validation result emitted" }
|
|
2663
|
+
],
|
|
2664
|
+
invariants: ["Validation must run for every blur event on a registered field"]
|
|
2665
|
+
},
|
|
2666
|
+
impl: (_state, events) => {
|
|
2667
|
+
const blurEvent = events.find((e) => e.tag === `${formName}.blur`);
|
|
2668
|
+
if (!blurEvent) return RuleResult.skip("No blur event");
|
|
2669
|
+
const payload = blurEvent.payload;
|
|
2670
|
+
const field = payload.field ?? "unknown";
|
|
2671
|
+
const value = payload.value;
|
|
2672
|
+
const valid = value !== null && value !== void 0 && value !== "";
|
|
2673
|
+
return RuleResult.emit([
|
|
2674
|
+
fact(`${formName}.field-validated`, {
|
|
2675
|
+
field,
|
|
2676
|
+
valid,
|
|
2677
|
+
error: valid ? null : `${field} is required`
|
|
2678
|
+
})
|
|
2679
|
+
]);
|
|
2680
|
+
}
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
if (submitGate) {
|
|
2684
|
+
constraints.push({
|
|
2685
|
+
id: `factory/${formName}.submit-gate`,
|
|
2686
|
+
description: `Prevents ${formName} submission when validation has not passed`,
|
|
2687
|
+
contract: {
|
|
2688
|
+
ruleId: `factory/${formName}.submit-gate`,
|
|
2689
|
+
behavior: `Blocks form submission until all fields are valid`,
|
|
2690
|
+
examples: [
|
|
2691
|
+
{ given: `${formName} is invalid`, when: "submit attempted", then: "violation \u2014 submission blocked" },
|
|
2692
|
+
{ given: `${formName} is valid`, when: "submit attempted", then: "passes" }
|
|
2693
|
+
],
|
|
2694
|
+
invariants: ["Form must not submit while any field has errors"]
|
|
2695
|
+
},
|
|
2696
|
+
impl: (state) => {
|
|
2697
|
+
const form = state.context.form;
|
|
2698
|
+
if (!form) return true;
|
|
2699
|
+
if (form.submitting && !form.valid) {
|
|
2700
|
+
return `${formName} cannot submit: validation has not passed`;
|
|
2701
|
+
}
|
|
2702
|
+
return true;
|
|
2703
|
+
}
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2706
|
+
rules.push({
|
|
2707
|
+
id: `factory/${formName}.dirty-tracking`,
|
|
2708
|
+
description: `Tracks whether ${formName} has unsaved changes`,
|
|
2709
|
+
eventTypes: [`${formName}.change`, `${formName}.reset`],
|
|
2710
|
+
contract: {
|
|
2711
|
+
ruleId: `factory/${formName}.dirty-tracking`,
|
|
2712
|
+
behavior: "Emits dirty state when form fields change, clears on reset",
|
|
2713
|
+
examples: [
|
|
2714
|
+
{ given: "field value changed", when: "form.change fires", then: "form.dirty emitted" },
|
|
2715
|
+
{ given: "form reset", when: "form.reset fires", then: "form.dirty retracted" }
|
|
2716
|
+
],
|
|
2717
|
+
invariants: ["Dirty state must reflect actual field changes"]
|
|
2718
|
+
},
|
|
2719
|
+
impl: (_state, events) => {
|
|
2720
|
+
const resetEvent = events.find((e) => e.tag === `${formName}.reset`);
|
|
2721
|
+
if (resetEvent) {
|
|
2722
|
+
return RuleResult.retract([`${formName}.dirty`], "Form reset");
|
|
2723
|
+
}
|
|
2724
|
+
const changeEvent = events.find((e) => e.tag === `${formName}.change`);
|
|
2725
|
+
if (changeEvent) {
|
|
2726
|
+
return RuleResult.emit([
|
|
2727
|
+
fact(`${formName}.dirty`, { dirty: true })
|
|
2728
|
+
]);
|
|
2729
|
+
}
|
|
2730
|
+
return RuleResult.skip("No form event");
|
|
2731
|
+
}
|
|
2732
|
+
});
|
|
2733
|
+
return { rules, constraints };
|
|
2734
|
+
}
|
|
2735
|
+
function navigationRules(config = {}) {
|
|
2736
|
+
const {
|
|
2737
|
+
dirtyGuard = false,
|
|
2738
|
+
authRequired = false
|
|
2739
|
+
} = config;
|
|
2740
|
+
const rules = [];
|
|
2741
|
+
const constraints = [];
|
|
2742
|
+
rules.push({
|
|
2743
|
+
id: "factory/navigation.handle",
|
|
2744
|
+
description: "Processes navigation requests and emits navigation facts",
|
|
2745
|
+
eventTypes: ["navigation.request"],
|
|
2746
|
+
contract: {
|
|
2747
|
+
ruleId: "factory/navigation.handle",
|
|
2748
|
+
behavior: "Emits navigation.allowed or navigation.blocked based on guards",
|
|
2749
|
+
examples: [
|
|
2750
|
+
{ given: "no guards active", when: "navigation requested", then: "navigation.allowed emitted" },
|
|
2751
|
+
...dirtyGuard ? [{ given: "form is dirty", when: "navigation requested", then: "navigation.blocked emitted" }] : [],
|
|
2752
|
+
...authRequired ? [{ given: "user not authenticated", when: "navigation requested", then: "navigation.blocked emitted" }] : []
|
|
2753
|
+
],
|
|
2754
|
+
invariants: [
|
|
2755
|
+
"Every navigation request must result in either allowed or blocked",
|
|
2756
|
+
...dirtyGuard ? ["Navigation must be blocked when dirty data exists"] : [],
|
|
2757
|
+
...authRequired ? ["Navigation must be blocked when not authenticated"] : []
|
|
2758
|
+
]
|
|
2759
|
+
},
|
|
2760
|
+
impl: (state, events) => {
|
|
2761
|
+
const navEvent = events.find((e) => e.tag === "navigation.request");
|
|
2762
|
+
if (!navEvent) return RuleResult.skip("No navigation request");
|
|
2763
|
+
const target = navEvent.payload?.target ?? "/";
|
|
2764
|
+
const reasons = [];
|
|
2765
|
+
if (dirtyGuard && state.context.dirty) {
|
|
2766
|
+
reasons.push("Unsaved changes will be lost");
|
|
2767
|
+
}
|
|
2768
|
+
if (authRequired && !state.context.authenticated) {
|
|
2769
|
+
reasons.push("Authentication required");
|
|
2770
|
+
}
|
|
2771
|
+
if (reasons.length > 0) {
|
|
2772
|
+
return RuleResult.emit([
|
|
2773
|
+
fact("navigation.blocked", { target, reasons })
|
|
2774
|
+
]);
|
|
2775
|
+
}
|
|
2776
|
+
return RuleResult.emit([
|
|
2777
|
+
fact("navigation.allowed", { target })
|
|
2778
|
+
]);
|
|
2779
|
+
}
|
|
2780
|
+
});
|
|
2781
|
+
if (dirtyGuard) {
|
|
2782
|
+
constraints.push({
|
|
2783
|
+
id: "factory/navigation.dirty-guard",
|
|
2784
|
+
description: "Prevents silent navigation when unsaved changes exist",
|
|
2785
|
+
contract: {
|
|
2786
|
+
ruleId: "factory/navigation.dirty-guard",
|
|
2787
|
+
behavior: "Blocks navigation when dirty state is true",
|
|
2788
|
+
examples: [
|
|
2789
|
+
{ given: "dirty is true", when: "navigation attempted", then: "violation" },
|
|
2790
|
+
{ given: "dirty is false", when: "navigation attempted", then: "passes" }
|
|
2791
|
+
],
|
|
2792
|
+
invariants: ["Must never silently lose unsaved changes"]
|
|
2793
|
+
},
|
|
2794
|
+
impl: (state) => {
|
|
2795
|
+
if (state.context.dirty && state.facts.some((f) => f.tag === "navigation.allowed")) {
|
|
2796
|
+
return "Navigation allowed while dirty \u2014 unsaved changes may be lost";
|
|
2797
|
+
}
|
|
2798
|
+
return true;
|
|
2799
|
+
}
|
|
2800
|
+
});
|
|
2801
|
+
}
|
|
2802
|
+
return { rules, constraints };
|
|
2803
|
+
}
|
|
2804
|
+
function dataRules(config = {}) {
|
|
2805
|
+
const {
|
|
2806
|
+
optimisticUpdate = false,
|
|
2807
|
+
rollbackOnError = false,
|
|
2808
|
+
cacheInvalidation = false,
|
|
2809
|
+
entityName = "data"
|
|
2810
|
+
} = config;
|
|
2811
|
+
const rules = [];
|
|
2812
|
+
const constraints = [];
|
|
2813
|
+
if (optimisticUpdate) {
|
|
2814
|
+
rules.push({
|
|
2815
|
+
id: `factory/${entityName}.optimistic-update`,
|
|
2816
|
+
description: `Applies optimistic update for ${entityName} while request is pending`,
|
|
2817
|
+
eventTypes: [`${entityName}.mutate`],
|
|
2818
|
+
contract: {
|
|
2819
|
+
ruleId: `factory/${entityName}.optimistic-update`,
|
|
2820
|
+
behavior: `Immediately emits updated ${entityName} state before server confirmation`,
|
|
2821
|
+
examples: [
|
|
2822
|
+
{ given: `${entityName} mutation requested`, when: "mutate event fires", then: "optimistic state emitted" }
|
|
2823
|
+
],
|
|
2824
|
+
invariants: [
|
|
2825
|
+
"Optimistic state must store original for rollback",
|
|
2826
|
+
"Optimistic update must be distinguishable from confirmed state"
|
|
2827
|
+
]
|
|
2828
|
+
},
|
|
2829
|
+
impl: (_state, events) => {
|
|
2830
|
+
const mutateEvent = events.find((e) => e.tag === `${entityName}.mutate`);
|
|
2831
|
+
if (!mutateEvent) return RuleResult.skip("No mutation event");
|
|
2832
|
+
const payload = mutateEvent.payload;
|
|
2833
|
+
return RuleResult.emit([
|
|
2834
|
+
fact(`${entityName}.optimistic`, {
|
|
2835
|
+
id: payload.id,
|
|
2836
|
+
data: payload.data,
|
|
2837
|
+
pending: true,
|
|
2838
|
+
timestamp: Date.now()
|
|
2839
|
+
})
|
|
2840
|
+
]);
|
|
2841
|
+
}
|
|
2842
|
+
});
|
|
2843
|
+
}
|
|
2844
|
+
if (rollbackOnError) {
|
|
2845
|
+
rules.push({
|
|
2846
|
+
id: `factory/${entityName}.rollback`,
|
|
2847
|
+
description: `Rolls back optimistic ${entityName} update on error`,
|
|
2848
|
+
eventTypes: [`${entityName}.error`],
|
|
2849
|
+
contract: {
|
|
2850
|
+
ruleId: `factory/${entityName}.rollback`,
|
|
2851
|
+
behavior: `Reverts to original ${entityName} state when mutation fails`,
|
|
2852
|
+
examples: [
|
|
2853
|
+
{ given: "optimistic update was applied", when: "server returns error", then: "rollback emitted, optimistic retracted" }
|
|
2854
|
+
],
|
|
2855
|
+
invariants: [
|
|
2856
|
+
"Rollback must restore original state exactly",
|
|
2857
|
+
"Optimistic facts must be retracted on rollback"
|
|
2858
|
+
]
|
|
2859
|
+
},
|
|
2860
|
+
impl: (_state, events) => {
|
|
2861
|
+
const errorEvent = events.find((e) => e.tag === `${entityName}.error`);
|
|
2862
|
+
if (!errorEvent) return RuleResult.skip("No error event");
|
|
2863
|
+
const payload = errorEvent.payload;
|
|
2864
|
+
const result = RuleResult.emit([
|
|
2865
|
+
fact(`${entityName}.rollback`, {
|
|
2866
|
+
id: payload.id,
|
|
2867
|
+
error: payload.error,
|
|
2868
|
+
timestamp: Date.now()
|
|
2869
|
+
})
|
|
2870
|
+
]);
|
|
2871
|
+
return result;
|
|
2872
|
+
}
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
if (cacheInvalidation) {
|
|
2876
|
+
rules.push({
|
|
2877
|
+
id: `factory/${entityName}.cache-invalidate`,
|
|
2878
|
+
description: `Invalidates ${entityName} cache when data changes are confirmed`,
|
|
2879
|
+
eventTypes: [`${entityName}.confirmed`, `${entityName}.deleted`],
|
|
2880
|
+
contract: {
|
|
2881
|
+
ruleId: `factory/${entityName}.cache-invalidate`,
|
|
2882
|
+
behavior: `Emits cache invalidation signal when ${entityName} is confirmed or deleted`,
|
|
2883
|
+
examples: [
|
|
2884
|
+
{ given: `${entityName} mutation confirmed`, when: "confirmed event fires", then: "cache.invalidate emitted" }
|
|
2885
|
+
],
|
|
2886
|
+
invariants: ["Stale cache entries must be invalidated after confirmed mutations"]
|
|
2887
|
+
},
|
|
2888
|
+
impl: (_state, events) => {
|
|
2889
|
+
const confirmEvent = events.find(
|
|
2890
|
+
(e) => e.tag === `${entityName}.confirmed` || e.tag === `${entityName}.deleted`
|
|
2891
|
+
);
|
|
2892
|
+
if (!confirmEvent) return RuleResult.skip("No confirmation event");
|
|
2893
|
+
const payload = confirmEvent.payload;
|
|
2894
|
+
return RuleResult.emit([
|
|
2895
|
+
fact(`${entityName}.cache-invalidate`, {
|
|
2896
|
+
id: payload.id,
|
|
2897
|
+
timestamp: Date.now()
|
|
2898
|
+
})
|
|
2899
|
+
]);
|
|
2900
|
+
}
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
constraints.push({
|
|
2904
|
+
id: `factory/${entityName}.integrity`,
|
|
2905
|
+
description: `Ensures ${entityName} state integrity \u2014 no orphaned optimistic updates`,
|
|
2906
|
+
contract: {
|
|
2907
|
+
ruleId: `factory/${entityName}.integrity`,
|
|
2908
|
+
behavior: "Detects orphaned optimistic updates without pending confirmation",
|
|
2909
|
+
examples: [
|
|
2910
|
+
{ given: "optimistic update exists without pending request", when: "checked", then: "violation" }
|
|
2911
|
+
],
|
|
2912
|
+
invariants: [`Every optimistic ${entityName} update must have a corresponding pending request`]
|
|
2913
|
+
},
|
|
2914
|
+
impl: (state) => {
|
|
2915
|
+
const pending = state.context.pending ?? {};
|
|
2916
|
+
const optimisticFacts = state.facts.filter((f) => f.tag === `${entityName}.optimistic`);
|
|
2917
|
+
for (const optFact of optimisticFacts) {
|
|
2918
|
+
const id = optFact.payload?.id;
|
|
2919
|
+
if (id && !pending[id]) {
|
|
2920
|
+
return `Orphaned optimistic update for ${entityName} id=${id} \u2014 no pending request`;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
return true;
|
|
2924
|
+
}
|
|
2925
|
+
});
|
|
2926
|
+
return { rules, constraints };
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// src/project/project.ts
|
|
2930
|
+
function defineGate(name, config) {
|
|
2931
|
+
const { expects, onSatisfied, onViolation } = config;
|
|
2932
|
+
const rule = {
|
|
2933
|
+
id: `gate/${name}`,
|
|
2934
|
+
description: `Feature gate: ${name} \u2014 requires: ${expects.join(", ")}`,
|
|
2935
|
+
eventTypes: ["gate.check", `gate.${name}.check`],
|
|
2936
|
+
contract: {
|
|
2937
|
+
ruleId: `gate/${name}`,
|
|
2938
|
+
behavior: `Opens gate "${name}" when all expectations are met: ${expects.join(", ")}`,
|
|
2939
|
+
examples: [
|
|
2940
|
+
{
|
|
2941
|
+
given: `all expectations satisfied: ${expects.join(", ")}`,
|
|
2942
|
+
when: "gate checked",
|
|
2943
|
+
then: `gate.${name}.open emitted${onSatisfied ? ` \u2192 ${onSatisfied}` : ""}`
|
|
2944
|
+
},
|
|
2945
|
+
{
|
|
2946
|
+
given: "one or more expectations unsatisfied",
|
|
2947
|
+
when: "gate checked",
|
|
2948
|
+
then: `gate.${name}.blocked emitted${onViolation ? ` \u2192 ${onViolation}` : ""}`
|
|
2949
|
+
}
|
|
2950
|
+
],
|
|
2951
|
+
invariants: [
|
|
2952
|
+
`Gate "${name}" must never open with unsatisfied expectations`,
|
|
2953
|
+
"Gate status must reflect current expectation state exactly"
|
|
2954
|
+
]
|
|
2955
|
+
},
|
|
2956
|
+
impl: (state, events) => {
|
|
2957
|
+
const gateEvent = events.find(
|
|
2958
|
+
(e) => e.tag === "gate.check" || e.tag === `gate.${name}.check`
|
|
2959
|
+
);
|
|
2960
|
+
if (!gateEvent) return RuleResult.skip("No gate check event");
|
|
2961
|
+
const expectationState = state.context.expectations ?? {};
|
|
2962
|
+
const satisfied = [];
|
|
2963
|
+
const unsatisfied = [];
|
|
2964
|
+
for (const exp of expects) {
|
|
2965
|
+
if (expectationState[exp]) {
|
|
2966
|
+
satisfied.push(exp);
|
|
2967
|
+
} else {
|
|
2968
|
+
unsatisfied.push(exp);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
const status = unsatisfied.length === 0 ? "open" : "blocked";
|
|
2972
|
+
const gateState = {
|
|
2973
|
+
name,
|
|
2974
|
+
status,
|
|
2975
|
+
satisfied,
|
|
2976
|
+
unsatisfied,
|
|
2977
|
+
lastChanged: Date.now()
|
|
2978
|
+
};
|
|
2979
|
+
const facts = [fact(`gate.${name}.status`, gateState)];
|
|
2980
|
+
if (status === "open" && onSatisfied) {
|
|
2981
|
+
facts.push(fact(`gate.${name}.action`, { action: onSatisfied }));
|
|
2982
|
+
} else if (status === "blocked" && onViolation) {
|
|
2983
|
+
facts.push(fact(`gate.${name}.action`, { action: onViolation }));
|
|
2984
|
+
}
|
|
2985
|
+
return RuleResult.emit(facts);
|
|
2986
|
+
}
|
|
2987
|
+
};
|
|
2988
|
+
const constraint = {
|
|
2989
|
+
id: `gate/${name}/integrity`,
|
|
2990
|
+
description: `Ensures gate "${name}" status matches expectation reality`,
|
|
2991
|
+
contract: {
|
|
2992
|
+
ruleId: `gate/${name}/integrity`,
|
|
2993
|
+
behavior: `Validates that gate "${name}" is not open when expectations are unmet`,
|
|
2994
|
+
examples: [
|
|
2995
|
+
{
|
|
2996
|
+
given: `gate ${name} is open but expectations unmet`,
|
|
2997
|
+
when: "constraint checked",
|
|
2998
|
+
then: "violation"
|
|
2999
|
+
}
|
|
3000
|
+
],
|
|
3001
|
+
invariants: [`Gate "${name}" must never report open when expectations are unsatisfied`]
|
|
3002
|
+
},
|
|
3003
|
+
impl: (state) => {
|
|
3004
|
+
const gateState = state.context.gates?.[name];
|
|
3005
|
+
if (!gateState) return true;
|
|
3006
|
+
if (gateState.status === "open" && gateState.unsatisfied.length > 0) {
|
|
3007
|
+
return `Gate "${name}" is open but has unsatisfied expectations: ${gateState.unsatisfied.join(", ")}`;
|
|
3008
|
+
}
|
|
3009
|
+
return true;
|
|
3010
|
+
}
|
|
3011
|
+
};
|
|
3012
|
+
return { rules: [rule], constraints: [constraint] };
|
|
3013
|
+
}
|
|
3014
|
+
function semverContract(config) {
|
|
3015
|
+
const { sources, invariants } = config;
|
|
3016
|
+
const rule = {
|
|
3017
|
+
id: "project/semver-check",
|
|
3018
|
+
description: `Checks version consistency across: ${sources.join(", ")}`,
|
|
3019
|
+
eventTypes: ["project.version-check"],
|
|
3020
|
+
contract: {
|
|
3021
|
+
ruleId: "project/semver-check",
|
|
3022
|
+
behavior: `Verifies version consistency across ${sources.length} sources`,
|
|
3023
|
+
examples: [
|
|
3024
|
+
{
|
|
3025
|
+
given: "all sources have version 1.2.3",
|
|
3026
|
+
when: "version check runs",
|
|
3027
|
+
then: "semver.consistent emitted"
|
|
3028
|
+
},
|
|
3029
|
+
{
|
|
3030
|
+
given: "package.json has 1.2.3 but README has 1.2.2",
|
|
3031
|
+
when: "version check runs",
|
|
3032
|
+
then: "semver.inconsistent emitted with diff"
|
|
3033
|
+
}
|
|
3034
|
+
],
|
|
3035
|
+
invariants: invariants.length > 0 ? invariants : ["All version sources must report the same semver string"]
|
|
3036
|
+
},
|
|
3037
|
+
impl: (_state, events) => {
|
|
3038
|
+
const checkEvent = events.find((e) => e.tag === "project.version-check");
|
|
3039
|
+
if (!checkEvent) return RuleResult.skip("No version check event");
|
|
3040
|
+
const versions = checkEvent.payload?.versions ?? {};
|
|
3041
|
+
const versionValues = Object.values(versions);
|
|
3042
|
+
const unique = new Set(versionValues);
|
|
3043
|
+
if (unique.size <= 1) {
|
|
3044
|
+
return RuleResult.emit([
|
|
3045
|
+
fact("semver.consistent", {
|
|
3046
|
+
version: versionValues[0] ?? "unknown",
|
|
3047
|
+
sources: Object.keys(versions)
|
|
3048
|
+
})
|
|
3049
|
+
]);
|
|
3050
|
+
}
|
|
3051
|
+
const report = {
|
|
3052
|
+
consistent: false,
|
|
3053
|
+
versions,
|
|
3054
|
+
violations: [`Version mismatch: ${JSON.stringify(versions)}`]
|
|
3055
|
+
};
|
|
3056
|
+
return RuleResult.emit([fact("semver.inconsistent", report)]);
|
|
3057
|
+
}
|
|
3058
|
+
};
|
|
3059
|
+
return { rules: [rule], constraints: [] };
|
|
3060
|
+
}
|
|
3061
|
+
function commitFromState(diff) {
|
|
3062
|
+
const parts = [];
|
|
3063
|
+
const bodyParts = [];
|
|
3064
|
+
const totalAdded = diff.rulesAdded.length + diff.contractsAdded.length + diff.expectationsAdded.length;
|
|
3065
|
+
const totalRemoved = diff.rulesRemoved.length + diff.contractsRemoved.length + diff.expectationsRemoved.length;
|
|
3066
|
+
const totalModified = diff.rulesModified.length;
|
|
3067
|
+
const hasGateChanges = diff.gateChanges.length > 0;
|
|
3068
|
+
if (totalAdded > 0 && totalRemoved === 0 && totalModified === 0) {
|
|
3069
|
+
if (diff.rulesAdded.length > 0) {
|
|
3070
|
+
const scope = inferScope(diff.rulesAdded);
|
|
3071
|
+
parts.push(`feat(${scope}): add ${formatIds(diff.rulesAdded)}`);
|
|
3072
|
+
} else if (diff.contractsAdded.length > 0) {
|
|
3073
|
+
parts.push(`feat(contracts): add contracts for ${formatIds(diff.contractsAdded)}`);
|
|
3074
|
+
} else {
|
|
3075
|
+
parts.push(`feat(expectations): add ${formatIds(diff.expectationsAdded)}`);
|
|
3076
|
+
}
|
|
3077
|
+
} else if (totalRemoved > 0 && totalAdded === 0) {
|
|
3078
|
+
if (diff.rulesRemoved.length > 0) {
|
|
3079
|
+
const scope = inferScope(diff.rulesRemoved);
|
|
3080
|
+
parts.push(`refactor(${scope}): remove ${formatIds(diff.rulesRemoved)}`);
|
|
3081
|
+
} else {
|
|
3082
|
+
parts.push(`refactor: remove ${totalRemoved} item(s)`);
|
|
3083
|
+
}
|
|
3084
|
+
} else if (totalModified > 0) {
|
|
3085
|
+
const scope = inferScope(diff.rulesModified);
|
|
3086
|
+
parts.push(`refactor(${scope}): update ${formatIds(diff.rulesModified)}`);
|
|
3087
|
+
} else if (hasGateChanges) {
|
|
3088
|
+
const gateNames = diff.gateChanges.map((g) => g.gate);
|
|
3089
|
+
parts.push(`chore(gates): ${formatIds(gateNames)} state changed`);
|
|
3090
|
+
} else {
|
|
3091
|
+
parts.push("chore: behavioral state update");
|
|
3092
|
+
}
|
|
3093
|
+
if (diff.rulesAdded.length > 0) bodyParts.push(`Rules added: ${diff.rulesAdded.join(", ")}`);
|
|
3094
|
+
if (diff.rulesRemoved.length > 0) bodyParts.push(`Rules removed: ${diff.rulesRemoved.join(", ")}`);
|
|
3095
|
+
if (diff.rulesModified.length > 0) bodyParts.push(`Rules modified: ${diff.rulesModified.join(", ")}`);
|
|
3096
|
+
if (diff.contractsAdded.length > 0) bodyParts.push(`Contracts added: ${diff.contractsAdded.join(", ")}`);
|
|
3097
|
+
if (diff.contractsRemoved.length > 0) bodyParts.push(`Contracts removed: ${diff.contractsRemoved.join(", ")}`);
|
|
3098
|
+
if (diff.expectationsAdded.length > 0) bodyParts.push(`Expectations added: ${diff.expectationsAdded.join(", ")}`);
|
|
3099
|
+
if (diff.expectationsRemoved.length > 0) bodyParts.push(`Expectations removed: ${diff.expectationsRemoved.join(", ")}`);
|
|
3100
|
+
for (const gc of diff.gateChanges) {
|
|
3101
|
+
bodyParts.push(`Gate "${gc.gate}": ${gc.from} \u2192 ${gc.to}`);
|
|
3102
|
+
}
|
|
3103
|
+
const subject = parts[0] || "chore: update";
|
|
3104
|
+
return bodyParts.length > 0 ? `${subject}
|
|
3105
|
+
|
|
3106
|
+
${bodyParts.join("\n")}` : subject;
|
|
3107
|
+
}
|
|
3108
|
+
function inferScope(ids) {
|
|
3109
|
+
if (ids.length === 0) return "rules";
|
|
3110
|
+
const prefixes = ids.map((id) => {
|
|
3111
|
+
const slash = id.indexOf("/");
|
|
3112
|
+
return slash > 0 ? id.slice(0, slash) : id;
|
|
3113
|
+
});
|
|
3114
|
+
const unique = new Set(prefixes);
|
|
3115
|
+
return unique.size === 1 ? prefixes[0] : "rules";
|
|
3116
|
+
}
|
|
3117
|
+
function formatIds(ids) {
|
|
3118
|
+
if (ids.length <= 3) return ids.join(", ");
|
|
3119
|
+
return `${ids.slice(0, 2).join(", ")} (+${ids.length - 2} more)`;
|
|
3120
|
+
}
|
|
3121
|
+
function branchRules(config) {
|
|
3122
|
+
const { naming, mergeConditions } = config;
|
|
3123
|
+
const namePattern = naming.replace("{name}", "(.+)").replace("{issue}", "(\\d+)");
|
|
3124
|
+
const nameRegex = new RegExp(`^${namePattern}$`);
|
|
3125
|
+
const rule = {
|
|
3126
|
+
id: "project/branch-check",
|
|
3127
|
+
description: `Validates branch naming (${naming}) and merge conditions`,
|
|
3128
|
+
eventTypes: ["project.branch-check"],
|
|
3129
|
+
contract: {
|
|
3130
|
+
ruleId: "project/branch-check",
|
|
3131
|
+
behavior: `Ensures branch follows "${naming}" pattern and merge conditions are met`,
|
|
3132
|
+
examples: [
|
|
3133
|
+
{
|
|
3134
|
+
given: `branch named "${naming.replace("{name}", "my-feature")}"`,
|
|
3135
|
+
when: "branch checked",
|
|
3136
|
+
then: "branch.valid emitted"
|
|
3137
|
+
},
|
|
3138
|
+
{
|
|
3139
|
+
given: 'branch named "random-name"',
|
|
3140
|
+
when: "branch checked",
|
|
3141
|
+
then: "branch.invalid emitted"
|
|
3142
|
+
}
|
|
3143
|
+
],
|
|
3144
|
+
invariants: [
|
|
3145
|
+
`Branch names must follow pattern: ${naming}`,
|
|
3146
|
+
`Merge requires: ${mergeConditions.join(", ")}`
|
|
3147
|
+
]
|
|
3148
|
+
},
|
|
3149
|
+
impl: (_state, events) => {
|
|
3150
|
+
const checkEvent = events.find((e) => e.tag === "project.branch-check");
|
|
3151
|
+
if (!checkEvent) return RuleResult.skip("No branch check event");
|
|
3152
|
+
const payload = checkEvent.payload;
|
|
3153
|
+
const branch = payload.branch ?? "";
|
|
3154
|
+
const conditions = payload.conditions ?? {};
|
|
3155
|
+
const validName = nameRegex.test(branch);
|
|
3156
|
+
const unmetConditions = mergeConditions.filter((c) => !conditions[c]);
|
|
3157
|
+
if (validName && unmetConditions.length === 0) {
|
|
3158
|
+
return RuleResult.emit([
|
|
3159
|
+
fact("branch.valid", { branch, mergeReady: true })
|
|
3160
|
+
]);
|
|
3161
|
+
}
|
|
3162
|
+
const reasons = [];
|
|
3163
|
+
if (!validName) reasons.push(`Branch name "${branch}" doesn't match pattern "${naming}"`);
|
|
3164
|
+
if (unmetConditions.length > 0) reasons.push(`Unmet merge conditions: ${unmetConditions.join(", ")}`);
|
|
3165
|
+
return RuleResult.emit([
|
|
3166
|
+
fact("branch.invalid", { branch, reasons, mergeReady: false })
|
|
3167
|
+
]);
|
|
3168
|
+
}
|
|
3169
|
+
};
|
|
3170
|
+
return { rules: [rule], constraints: [] };
|
|
3171
|
+
}
|
|
3172
|
+
function lintGate(config = {}) {
|
|
3173
|
+
const expects = ["lint-passes", ...config.additionalExpects ?? []];
|
|
3174
|
+
return defineGate("lint", {
|
|
3175
|
+
expects,
|
|
3176
|
+
onSatisfied: "lint-passed",
|
|
3177
|
+
onViolation: "lint-failed"
|
|
3178
|
+
});
|
|
3179
|
+
}
|
|
3180
|
+
function formatGate(config = {}) {
|
|
3181
|
+
const expects = ["format-passes", ...config.additionalExpects ?? []];
|
|
3182
|
+
return defineGate("format", {
|
|
3183
|
+
expects,
|
|
3184
|
+
onSatisfied: "format-passed",
|
|
3185
|
+
onViolation: "format-failed"
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
function expectationGate(config = {}) {
|
|
3189
|
+
const expects = ["expectations-verified", ...config.additionalExpects ?? []];
|
|
3190
|
+
return defineGate("expectations", {
|
|
3191
|
+
expects,
|
|
3192
|
+
onSatisfied: "expectations-passed",
|
|
3193
|
+
onViolation: "expectations-failed"
|
|
3194
|
+
});
|
|
3195
|
+
}
|
|
1900
3196
|
export {
|
|
1901
3197
|
AcknowledgeContractGap,
|
|
1902
3198
|
ActorManager,
|
|
@@ -1908,6 +3204,8 @@ export {
|
|
|
1908
3204
|
ContractMissing,
|
|
1909
3205
|
ContractUpdated,
|
|
1910
3206
|
ContractValidated,
|
|
3207
|
+
Expectation,
|
|
3208
|
+
ExpectationSet,
|
|
1911
3209
|
ReactiveLogicEngine2 as FrameworkAgnosticReactiveEngine,
|
|
1912
3210
|
InMemoryPraxisDB,
|
|
1913
3211
|
LogicEngine,
|
|
@@ -1921,6 +3219,7 @@ export {
|
|
|
1921
3219
|
PraxisSchemaRegistry,
|
|
1922
3220
|
ReactiveLogicEngine,
|
|
1923
3221
|
RegistryIntrospector,
|
|
3222
|
+
RuleResult,
|
|
1924
3223
|
StateDocsGenerator,
|
|
1925
3224
|
TerminalAdapter,
|
|
1926
3225
|
ValidateContracts,
|
|
@@ -1928,9 +3227,12 @@ export {
|
|
|
1928
3227
|
attachTauriToEngine,
|
|
1929
3228
|
attachToEngine,
|
|
1930
3229
|
attachUnumToEngine,
|
|
3230
|
+
auditCompleteness,
|
|
3231
|
+
branchRules,
|
|
1931
3232
|
canvasToMermaid,
|
|
1932
3233
|
canvasToSchema,
|
|
1933
3234
|
canvasToYaml,
|
|
3235
|
+
commitFromState,
|
|
1934
3236
|
createBehaviorLedger,
|
|
1935
3237
|
createCanvasEditor,
|
|
1936
3238
|
createChronicle,
|
|
@@ -1953,21 +3255,33 @@ export {
|
|
|
1953
3255
|
createTauriPraxisAdapter,
|
|
1954
3256
|
createTerminalAdapter,
|
|
1955
3257
|
createTimerActor,
|
|
3258
|
+
createUIModule,
|
|
1956
3259
|
createUnifiedApp,
|
|
1957
3260
|
createUnumAdapter,
|
|
3261
|
+
dataRules,
|
|
1958
3262
|
defineConstraint,
|
|
1959
3263
|
defineContract,
|
|
1960
3264
|
defineEvent,
|
|
1961
3265
|
defineFact,
|
|
3266
|
+
defineGate,
|
|
1962
3267
|
defineModule,
|
|
1963
3268
|
defineRule,
|
|
3269
|
+
dirtyGuardRule,
|
|
3270
|
+
errorDisplayRule,
|
|
3271
|
+
expectBehavior,
|
|
3272
|
+
expectationGate,
|
|
3273
|
+
fact,
|
|
1964
3274
|
filterEvents,
|
|
1965
3275
|
filterFacts,
|
|
1966
3276
|
findEvent,
|
|
1967
3277
|
findFact,
|
|
3278
|
+
formRules,
|
|
3279
|
+
formatGate,
|
|
3280
|
+
formatReport,
|
|
1968
3281
|
formatValidationReport,
|
|
1969
3282
|
formatValidationReportJSON,
|
|
1970
3283
|
formatValidationReportSARIF,
|
|
3284
|
+
formatVerificationReport,
|
|
1971
3285
|
generateDocs,
|
|
1972
3286
|
generateId,
|
|
1973
3287
|
generateTauriConfig,
|
|
@@ -1975,16 +3289,32 @@ export {
|
|
|
1975
3289
|
getEventPath,
|
|
1976
3290
|
getFactPath,
|
|
1977
3291
|
getSchemaPath,
|
|
3292
|
+
initGateRule,
|
|
3293
|
+
inputRules,
|
|
1978
3294
|
isContract,
|
|
3295
|
+
lintGate,
|
|
1979
3296
|
loadSchema,
|
|
1980
3297
|
loadSchemaFromFile,
|
|
1981
3298
|
loadSchemaFromJson,
|
|
1982
3299
|
loadSchemaFromYaml,
|
|
3300
|
+
loadingGateRule,
|
|
3301
|
+
mustBeInitializedConstraint,
|
|
3302
|
+
navigationRequest,
|
|
3303
|
+
navigationRules,
|
|
3304
|
+
noInteractionWhileLoadingConstraint,
|
|
3305
|
+
offlineIndicatorRule,
|
|
1983
3306
|
registerSchema,
|
|
3307
|
+
resizeEvent,
|
|
1984
3308
|
runTerminalCommand,
|
|
1985
3309
|
schemaToCanvas,
|
|
3310
|
+
semverContract,
|
|
3311
|
+
toastRules,
|
|
3312
|
+
uiModule,
|
|
3313
|
+
uiStateChanged,
|
|
1986
3314
|
validateContracts,
|
|
1987
3315
|
validateForGeneration,
|
|
1988
3316
|
validateSchema,
|
|
1989
|
-
validateWithGuardian
|
|
3317
|
+
validateWithGuardian,
|
|
3318
|
+
verify,
|
|
3319
|
+
viewportRule
|
|
1990
3320
|
};
|