@speedkit/cli 4.25.2 → 4.27.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.
Files changed (150) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +192 -19
  3. package/dist/commands/auto-prewarm.d.ts +8 -1
  4. package/dist/commands/auto-prewarm.js +18 -7
  5. package/dist/commands/generate-pop-config.d.ts +1 -0
  6. package/dist/commands/generate-pop-config.js +9 -3
  7. package/dist/commands/prewarm.js +3 -1
  8. package/dist/commands/query/parameter.d.ts +26 -0
  9. package/dist/commands/query/parameter.js +27 -0
  10. package/dist/commands/query/prewarm.d.ts +31 -0
  11. package/dist/commands/query/prewarm.js +31 -0
  12. package/dist/commands/revalidate.js +8 -6
  13. package/dist/helpers/clipboard.d.ts +19 -0
  14. package/dist/helpers/clipboard.js +70 -0
  15. package/dist/helpers/evaluate-speed-kit-config.d.ts +8 -18
  16. package/dist/helpers/evaluate-speed-kit-config.js +8 -6
  17. package/dist/helpers/evaluate-speed-kit-config.spec.d.ts +1 -0
  18. package/dist/helpers/evaluate-speed-kit-config.spec.js +78 -0
  19. package/dist/models/cli-parameters.d.ts +73 -0
  20. package/dist/models/cli-parameters.js +151 -0
  21. package/dist/models/cli-parameters.spec.d.ts +1 -0
  22. package/dist/models/cli-parameters.spec.js +47 -0
  23. package/dist/services/athena/athena-service.d.ts +2 -0
  24. package/dist/services/athena/athena-service.js +15 -4
  25. package/dist/services/athena/athena-service.spec.d.ts +1 -0
  26. package/dist/services/athena/athena-service.spec.js +74 -0
  27. package/dist/services/deploy/handler/install-resource-handler.js +3 -4
  28. package/dist/services/onboarding/onboarding-service-factory.js +2 -2
  29. package/dist/services/pop-config/pop-config-service.d.ts +8 -1
  30. package/dist/services/pop-config/pop-config-service.js +16 -28
  31. package/dist/services/prewarm/assets/asset-api-client.d.ts +1 -1
  32. package/dist/services/prewarm/assets/asset-api-client.js +5 -4
  33. package/dist/services/prewarm/auto-pre-warm-factory.d.ts +12 -2
  34. package/dist/services/prewarm/auto-pre-warm-factory.js +20 -6
  35. package/dist/services/prewarm/csv-reader.d.ts +21 -2
  36. package/dist/services/prewarm/csv-reader.js +71 -9
  37. package/dist/services/prewarm/csv-reader.spec.d.ts +1 -0
  38. package/dist/services/prewarm/csv-reader.spec.js +75 -0
  39. package/dist/services/prewarm/index.d.ts +1 -0
  40. package/dist/services/prewarm/index.js +1 -0
  41. package/dist/services/prewarm/pre-warm-factory.d.ts +0 -2
  42. package/dist/services/prewarm/pre-warm-factory.js +8 -11
  43. package/dist/services/prewarm/pre-warm-model.d.ts +14 -1
  44. package/dist/services/prewarm/pre-warm-model.js +0 -1
  45. package/dist/services/prewarm/pre-warm-service.d.ts +9 -4
  46. package/dist/services/prewarm/pre-warm-service.js +18 -14
  47. package/dist/services/prewarm/prewarm-targets.d.ts +12 -0
  48. package/dist/services/prewarm/prewarm-targets.js +18 -0
  49. package/dist/services/prewarm/prewarm-targets.spec.d.ts +1 -0
  50. package/dist/services/prewarm/prewarm-targets.spec.js +29 -0
  51. package/dist/services/query-builder/error/match-matches-nothing-error.d.ts +11 -0
  52. package/dist/services/query-builder/error/match-matches-nothing-error.js +17 -0
  53. package/dist/services/query-builder/queries/device.d.ts +21 -0
  54. package/dist/services/query-builder/queries/device.js +26 -0
  55. package/dist/services/query-builder/queries/index.d.ts +6 -0
  56. package/dist/services/query-builder/queries/index.js +6 -0
  57. package/dist/services/query-builder/queries/page-filter.d.ts +39 -0
  58. package/dist/services/query-builder/queries/page-filter.js +135 -0
  59. package/dist/services/query-builder/queries/page-source.d.ts +30 -0
  60. package/dist/services/query-builder/queries/page-source.js +79 -0
  61. package/dist/services/query-builder/queries/parameter-query.d.ts +14 -0
  62. package/dist/services/query-builder/queries/parameter-query.js +142 -0
  63. package/dist/services/query-builder/queries/pops-query.d.ts +27 -0
  64. package/dist/services/query-builder/queries/pops-query.js +65 -0
  65. package/dist/services/query-builder/queries/prewarm-query.d.ts +18 -0
  66. package/dist/services/query-builder/queries/prewarm-query.js +227 -0
  67. package/dist/services/query-builder/queries/query-model.d.ts +97 -0
  68. package/dist/services/query-builder/queries/query-model.js +19 -0
  69. package/dist/services/query-builder/queries/spec/golden-configs.d.ts +18 -0
  70. package/dist/services/query-builder/queries/spec/golden-configs.js +116 -0
  71. package/dist/services/query-builder/queries/spec/golden.spec.d.ts +1 -0
  72. package/dist/services/query-builder/queries/spec/golden.spec.js +161 -0
  73. package/dist/services/query-builder/queries/spec/pops-query.spec.d.ts +1 -0
  74. package/dist/services/query-builder/queries/spec/pops-query.spec.js +58 -0
  75. package/dist/services/query-builder/queries/spec/queries.spec.d.ts +1 -0
  76. package/dist/services/query-builder/queries/spec/queries.spec.js +312 -0
  77. package/dist/services/query-builder/queries/spec/reported-bugs.spec.d.ts +1 -0
  78. package/dist/services/query-builder/queries/spec/reported-bugs.spec.js +86 -0
  79. package/dist/services/query-builder/query-builder-factory.d.ts +2 -2
  80. package/dist/services/query-builder/query-builder-factory.js +35 -7
  81. package/dist/services/query-builder/query-builder-model.d.ts +31 -19
  82. package/dist/services/query-builder/query-builder-model.js +5 -5
  83. package/dist/services/query-builder/query-builder-service.d.ts +26 -5
  84. package/dist/services/query-builder/query-builder-service.js +157 -44
  85. package/dist/services/query-builder/query-command.d.ts +26 -0
  86. package/dist/services/query-builder/query-command.js +35 -0
  87. package/dist/services/query-builder/rules/index.d.ts +8 -0
  88. package/dist/services/query-builder/rules/index.js +8 -0
  89. package/dist/services/query-builder/rules/match-pattern.d.ts +51 -0
  90. package/dist/services/query-builder/rules/match-pattern.js +141 -0
  91. package/dist/services/query-builder/rules/pattern-language.d.ts +59 -0
  92. package/dist/services/query-builder/rules/pattern-language.js +159 -0
  93. package/dist/services/query-builder/rules/rule-model.d.ts +107 -0
  94. package/dist/services/query-builder/rules/rule-model.js +54 -0
  95. package/dist/services/query-builder/rules/rule-parser.d.ts +33 -0
  96. package/dist/services/query-builder/rules/rule-parser.js +189 -0
  97. package/dist/services/query-builder/rules/rule-reducer.d.ts +71 -0
  98. package/dist/services/query-builder/rules/rule-reducer.js +383 -0
  99. package/dist/services/query-builder/rules/rule-relevance.d.ts +43 -0
  100. package/dist/services/query-builder/rules/rule-relevance.js +69 -0
  101. package/dist/services/query-builder/rules/rule-to-sql.d.ts +51 -0
  102. package/dist/services/query-builder/rules/rule-to-sql.js +75 -0
  103. package/dist/services/query-builder/rules/spec/pattern-language.spec.d.ts +1 -0
  104. package/dist/services/query-builder/rules/spec/pattern-language.spec.js +90 -0
  105. package/dist/services/query-builder/rules/spec/rule-parser.spec.d.ts +1 -0
  106. package/dist/services/query-builder/rules/spec/rule-parser.spec.js +150 -0
  107. package/dist/services/query-builder/rules/spec/rule-reducer.spec.d.ts +1 -0
  108. package/dist/services/query-builder/rules/spec/rule-reducer.spec.js +169 -0
  109. package/dist/services/query-builder/rules/spec/rule-relevance.spec.d.ts +1 -0
  110. package/dist/services/query-builder/rules/spec/rule-relevance.spec.js +88 -0
  111. package/dist/services/query-builder/rules/spec/rule-to-sql.spec.d.ts +1 -0
  112. package/dist/services/query-builder/rules/spec/rule-to-sql.spec.js +110 -0
  113. package/dist/services/query-builder/rules/spec/runtime-semantics.spec.d.ts +1 -0
  114. package/dist/services/query-builder/rules/spec/runtime-semantics.spec.js +55 -0
  115. package/dist/services/query-builder/rules/spec/strip-parameters.spec.d.ts +1 -0
  116. package/dist/services/query-builder/rules/spec/strip-parameters.spec.js +85 -0
  117. package/dist/services/query-builder/rules/strip-parameters.d.ts +39 -0
  118. package/dist/services/query-builder/rules/strip-parameters.js +197 -0
  119. package/dist/services/query-builder/sql/expression.d.ts +73 -0
  120. package/dist/services/query-builder/sql/expression.js +133 -0
  121. package/dist/services/query-builder/sql/format.d.ts +14 -0
  122. package/dist/services/query-builder/sql/format.js +48 -0
  123. package/dist/services/query-builder/sql/index.d.ts +4 -0
  124. package/dist/services/query-builder/sql/index.js +4 -0
  125. package/dist/services/query-builder/sql/predicate.d.ts +66 -0
  126. package/dist/services/query-builder/sql/predicate.js +124 -0
  127. package/dist/services/query-builder/sql/regex.d.ts +28 -0
  128. package/dist/services/query-builder/sql/regex.js +81 -0
  129. package/dist/services/query-builder/sql/spec/expression.spec.d.ts +1 -0
  130. package/dist/services/query-builder/sql/spec/expression.spec.js +75 -0
  131. package/dist/services/query-builder/sql/spec/format.spec.d.ts +1 -0
  132. package/dist/services/query-builder/sql/spec/format.spec.js +37 -0
  133. package/dist/services/query-builder/sql/spec/predicate.spec.d.ts +1 -0
  134. package/dist/services/query-builder/sql/spec/predicate.spec.js +78 -0
  135. package/dist/services/query-builder/sql/spec/regex.spec.d.ts +1 -0
  136. package/dist/services/query-builder/sql/spec/regex.spec.js +62 -0
  137. package/dist/services/revalidate/revalidate-model.d.ts +9 -3
  138. package/dist/services/revalidate/revalidate-model.js +12 -3
  139. package/oclif.manifest.json +354 -104
  140. package/package.json +11 -2
  141. package/dist/commands/build-parameter-query.d.ts +0 -14
  142. package/dist/commands/build-parameter-query.js +0 -25
  143. package/dist/commands/build-prewarm-query.d.ts +0 -16
  144. package/dist/commands/build-prewarm-query.js +0 -37
  145. package/dist/helpers/build-query-helper.d.ts +0 -32
  146. package/dist/helpers/build-query-helper.js +0 -223
  147. package/dist/helpers/get-parsed-config.d.ts +0 -15
  148. package/dist/helpers/get-parsed-config.js +0 -62
  149. package/dist/services/onboarding/dashboard/parameter-query-builder.d.ts +0 -8
  150. package/dist/services/onboarding/dashboard/parameter-query-builder.js +0 -63
@@ -0,0 +1,189 @@
1
+ import { types } from "node:util";
2
+ import { ConditionKey } from "@speedkit/rules";
3
+ import { escapeRegexLiteral } from "../sql/index.js";
4
+ import { CRITERIA_OUTSIDE_THE_URL, DEFAULT_RULE_SET_VERSION, } from "./rule-model.js";
5
+ const SUBJECTS = ["host", "pathname", "url"];
6
+ /** Reads the rule sets a query is built from out of an evaluated config. */
7
+ export function parseRuleSets(config) {
8
+ const speedKitConfig = asRecord(config) ?? {};
9
+ const ruleSetVersion = parseRuleSetVersion(speedKitConfig);
10
+ return {
11
+ blacklist: parseRules(speedKitConfig.blacklist, "blacklist", ruleSetVersion),
12
+ disabledScopes: parseDisabledScopes(speedKitConfig.disabledScopes),
13
+ disabledSites: parseRules(speedKitConfig.disabledSites, "disabledSites", ruleSetVersion),
14
+ enabledSites: parseRules(speedKitConfig.enabledSites, "enabledSites", ruleSetVersion),
15
+ ruleSetVersion,
16
+ stripQueryParams: parseStripQueryParams(speedKitConfig.stripQueryParams, ruleSetVersion),
17
+ whitelist: parseRules(speedKitConfig.whitelist, "whitelist", ruleSetVersion),
18
+ };
19
+ }
20
+ /** The declared rule-set version, or 1 for a config that declares none. */
21
+ export function parseRuleSetVersion(config) {
22
+ const ruleSetConfig = asRecord(asRecord(config)?.ruleSetConfig);
23
+ const version = Number(ruleSetConfig?.version);
24
+ return Number.isFinite(version) && version > 0
25
+ ? version
26
+ : DEFAULT_RULE_SET_VERSION;
27
+ }
28
+ /** Normalizes a `SpeedKitRule[]` — anything else yields no rules. */
29
+ export function parseRules(value, source, ruleSetVersion) {
30
+ if (!Array.isArray(value)) {
31
+ return [];
32
+ }
33
+ const rules = [];
34
+ for (const [index, entry] of value.entries()) {
35
+ const rule = parseRule(entry, `${source}[${index}]`, ruleSetVersion);
36
+ if (rule) {
37
+ rules.push(rule);
38
+ }
39
+ }
40
+ return rules;
41
+ }
42
+ /**
43
+ * Normalizes a `Condition` — a string, a regular expression, or an array of
44
+ * both — into a flat list of patterns.
45
+ */
46
+ export function parseCondition(value) {
47
+ if (value === undefined || value === null) {
48
+ return [];
49
+ }
50
+ const entries = Array.isArray(value) ? value : [value];
51
+ const patterns = [];
52
+ for (const entry of entries) {
53
+ // The config is evaluated in a vm context, so its regular expressions come
54
+ // from another realm: `instanceof RegExp` is false for them here, and it
55
+ // is false again in `testCondition`. Rebuilding them in this realm is what
56
+ // makes every later check see a regular expression.
57
+ if (types.isRegExp(entry)) {
58
+ const { flags, source } = entry;
59
+ patterns.push({ kind: "regexp", value: new RegExp(source, flags) });
60
+ continue;
61
+ }
62
+ if (typeof entry === "string" || typeof entry === "number") {
63
+ patterns.push({ kind: "string", value: String(entry) });
64
+ }
65
+ }
66
+ return patterns;
67
+ }
68
+ function parseRule(value, source, ruleSetVersion) {
69
+ const rule = asRecord(value);
70
+ if (!rule) {
71
+ return undefined;
72
+ }
73
+ const conditions = [];
74
+ for (const subject of SUBJECTS) {
75
+ const patterns = parseCondition(rule[subject]);
76
+ if (patterns.length > 0) {
77
+ conditions.push({ patterns, subject });
78
+ }
79
+ }
80
+ const version = Number(rule.ruleSetVersion);
81
+ const keys = Object.values(ConditionKey).filter((key) => rule[key] !== undefined);
82
+ return {
83
+ conditions,
84
+ matchesNothing: keys.length === 0 ||
85
+ // An empty condition is satisfied by nothing, and every condition of a
86
+ // rule has to hold.
87
+ keys.some((key) => Array.isArray(rule[key]) && rule[key].length === 0),
88
+ contentTypes: parseContentTypes(rule.contentType),
89
+ criteriaOutsideTheUrl: CRITERIA_OUTSIDE_THE_URL.filter((criterion) => rule[criterion] !== undefined),
90
+ ruleSetVersion: Number.isFinite(version) && version > 0 ? version : ruleSetVersion,
91
+ source,
92
+ };
93
+ }
94
+ function parseContentTypes(value) {
95
+ if (value === undefined || value === null) {
96
+ return undefined;
97
+ }
98
+ const entries = Array.isArray(value) ? value : [value];
99
+ const contentTypes = entries
100
+ .filter((entry) => typeof entry === "string")
101
+ .map((entry) => entry.toLowerCase());
102
+ return contentTypes.length > 0 ? contentTypes : undefined;
103
+ }
104
+ /**
105
+ * Turns `disabledScopes` into rules.
106
+ *
107
+ * A scope is a service-worker scope, not a Speed Kit condition: a
108
+ * case-sensitive path prefix, matched on the URL string with no regular
109
+ * expression and no wildcard, so `/checkout` covers `/checkout/payment` and
110
+ * `/checkoutTunnel` but not `/en/checkout`. Written as an anchored pattern it
111
+ * says exactly that, and it can then be read, reduced and translated like
112
+ * every other rule.
113
+ *
114
+ * Pages under a scope are served by an empty service worker, so Speed Kit
115
+ * never touches them — they belong in neither a pre-warm list nor a parameter
116
+ * analysis.
117
+ */
118
+ export function parseDisabledScopes(value) {
119
+ if (!Array.isArray(value)) {
120
+ return [];
121
+ }
122
+ const rules = [];
123
+ for (const [index, entry] of value.entries()) {
124
+ if (typeof entry !== "string" || entry.length === 0) {
125
+ continue;
126
+ }
127
+ rules.push({
128
+ conditions: [
129
+ {
130
+ patterns: [
131
+ {
132
+ kind: "regexp",
133
+ value: new RegExp(`^${escapeRegexLiteral(entry)}`),
134
+ },
135
+ ],
136
+ subject: "pathname",
137
+ },
138
+ ],
139
+ criteriaOutsideTheUrl: [],
140
+ matchesNothing: false,
141
+ // Version 2 keeps the query string out of the subject, which is what a
142
+ // path prefix is matched against.
143
+ ruleSetVersion: 2,
144
+ source: `disabledScopes[${index}]`,
145
+ });
146
+ }
147
+ return rules;
148
+ }
149
+ /**
150
+ * Normalizes `stripQueryParams`, which is either a bare condition (every
151
+ * parameter matching it is stripped everywhere) or a list of rules that scope
152
+ * the stripping to certain pages or resources.
153
+ */
154
+ export function parseStripQueryParams(value, ruleSetVersion) {
155
+ if (value === undefined || value === null) {
156
+ return [];
157
+ }
158
+ const entries = Array.isArray(value) ? value : [value];
159
+ // A bare condition: strings and regular expressions, no scoping.
160
+ const bare = entries.filter((entry) => typeof entry === "string" || entry instanceof RegExp);
161
+ if (bare.length === entries.length) {
162
+ const params = parseCondition(bare);
163
+ return params.length > 0
164
+ ? [{ params, ruleSetVersion, source: "stripQueryParams" }]
165
+ : [];
166
+ }
167
+ const rules = [];
168
+ for (const [index, entry] of entries.entries()) {
169
+ const rule = asRecord(entry);
170
+ const params = parseCondition(rule?.params);
171
+ if (!rule || params.length === 0) {
172
+ continue;
173
+ }
174
+ const source = `stripQueryParams[${index}]`;
175
+ rules.push({
176
+ enabledSites: parseRules(rule.enabledSites, `${source}.enabledSites`, ruleSetVersion),
177
+ params,
178
+ rules: parseRules(rule.rules, `${source}.rules`, ruleSetVersion),
179
+ ruleSetVersion,
180
+ source,
181
+ });
182
+ }
183
+ return rules;
184
+ }
185
+ function asRecord(value) {
186
+ return typeof value === "object" && value !== null && !Array.isArray(value)
187
+ ? value
188
+ : undefined;
189
+ }
@@ -0,0 +1,71 @@
1
+ import { NormalizedRule } from "./rule-model.js";
2
+ import { RuleNote } from "./rule-relevance.js";
3
+ /**
4
+ * How much comparing the reductions may do in total.
5
+ *
6
+ * Deciding one pattern against another is cheap for the patterns configs
7
+ * normally use, but a handful of them build automata whose determinisation
8
+ * costs seconds. Reducing is an optimisation — a query that keeps a redundant
9
+ * rule is still correct — so the work is capped.
10
+ *
11
+ * The cap counts comparisons rather than milliseconds. A clock makes the
12
+ * result depend on how busy the machine was: the same config would reduce
13
+ * further on a quiet laptop than in CI, and two runs would disagree about a
14
+ * query nobody changed. Counting the work makes the output a function of the
15
+ * config alone, which is what a golden query and a snapshot diff need.
16
+ */
17
+ export interface ReductionBudget {
18
+ /** True once no comparison is left. */
19
+ exhausted: () => boolean;
20
+ /** Takes one comparison from the budget; false when there was none left. */
21
+ spend: () => boolean;
22
+ }
23
+ /**
24
+ * The default budget for one query build.
25
+ *
26
+ * Calibrated against the configs in the onboarding repo: a config whose rules
27
+ * can be reduced spends far less than this — the largest reduction observed
28
+ * needs a few hundred — while the pathological ones, whose automata blow up on
29
+ * determinisation, are stopped before the command starts to feel slow.
30
+ */
31
+ export declare const BUDGET_COMPARISONS = 800;
32
+ /** A budget of that many comparisons. */
33
+ export declare function reductionBudget(comparisons?: number): ReductionBudget;
34
+ export interface ReductionResult {
35
+ /** What was reduced away, and what made it redundant. */
36
+ readonly notes: RuleNote[];
37
+ readonly rules: NormalizedRule[];
38
+ }
39
+ /**
40
+ * The rules of one set, with everything a sibling already covers removed.
41
+ *
42
+ * Every rule set is a disjunction, and so is the entry list of a condition, so
43
+ * whatever is contained in something next to it contributes nothing: `a or b`
44
+ * is `b` when `a` implies `b`. Dropping it cannot change the result — it only
45
+ * shortens the query, which is the point.
46
+ */
47
+ export declare function absorbRules(rules: readonly NormalizedRule[], budget?: ReductionBudget): ReductionResult;
48
+ /**
49
+ * The rules of an excluding set — `blacklist`, `disabledSites` — with
50
+ * everything that can never fire removed.
51
+ *
52
+ * A page has to pass every including set to reach the result at all, so an
53
+ * entry excluding pages none of them lets through excludes nothing. This is
54
+ * the bulk of what makes a generated query long: a config guards checkout,
55
+ * account and login while `enabledSites` only ever allowed the homepage and
56
+ * product pages. Configs write those guards as entries of a single rule, so
57
+ * the reduction works per entry and drops the rule only once a condition has
58
+ * no entry left that could match.
59
+ *
60
+ * @param reachable - The including sets, each a disjunction, all of which have
61
+ * to hold. An empty set means "everything" and is left out by the caller.
62
+ */
63
+ export declare function dropUnreachableRules(rules: readonly NormalizedRule[], reachable: readonly (readonly NormalizedRule[])[], budget?: ReductionBudget): ReductionResult;
64
+ /**
65
+ * Rules of an including set that an excluding rule covers completely — pages
66
+ * the config means to accelerate but never will.
67
+ *
68
+ * Unlike the reductions above this changes nothing about the query: it is a
69
+ * finding about the config, and the caller reports it.
70
+ */
71
+ export declare function findShadowedRules(included: readonly NormalizedRule[], excluding: readonly NormalizedRule[], budget?: ReductionBudget): RuleNote[];
@@ -0,0 +1,383 @@
1
+ import { conditionLanguage, isInside, languagesDisjoint, outsideOf, } from "./pattern-language.js";
2
+ import { resolvedSubject, } from "./rule-model.js";
3
+ /**
4
+ * The default budget for one query build.
5
+ *
6
+ * Calibrated against the configs in the onboarding repo: a config whose rules
7
+ * can be reduced spends far less than this — the largest reduction observed
8
+ * needs a few hundred — while the pathological ones, whose automata blow up on
9
+ * determinisation, are stopped before the command starts to feel slow.
10
+ */
11
+ export const BUDGET_COMPARISONS = 800;
12
+ /** A budget of that many comparisons. */
13
+ export function reductionBudget(comparisons = BUDGET_COMPARISONS) {
14
+ let left = Math.max(0, Math.floor(comparisons));
15
+ return {
16
+ exhausted: () => left <= 0,
17
+ spend: () => left-- > 0,
18
+ };
19
+ }
20
+ /** A budget that never runs out. */
21
+ const UNLIMITED = {
22
+ exhausted: () => false,
23
+ spend: () => true,
24
+ };
25
+ /** The note left behind when the budget ran out. */
26
+ const STOPPED_EARLY = {
27
+ reason: `stopped after ${BUDGET_COMPARISONS} comparisons — rules that could still be reduced were kept`,
28
+ source: "reduction",
29
+ };
30
+ /** Every subject a query can provide. */
31
+ const SUBJECTS = [
32
+ "host",
33
+ "path",
34
+ "pathWithSearch",
35
+ "url",
36
+ ];
37
+ /**
38
+ * The rules of one set, with everything a sibling already covers removed.
39
+ *
40
+ * Every rule set is a disjunction, and so is the entry list of a condition, so
41
+ * whatever is contained in something next to it contributes nothing: `a or b`
42
+ * is `b` when `a` implies `b`. Dropping it cannot change the result — it only
43
+ * shortens the query, which is the point.
44
+ */
45
+ export function absorbRules(rules, budget = UNLIMITED) {
46
+ const notes = [];
47
+ const kept = [];
48
+ for (const [index, rule] of rules.entries()) {
49
+ if (budget.exhausted()) {
50
+ notes.push(STOPPED_EARLY);
51
+ kept.push(...rules.slice(index));
52
+ break;
53
+ }
54
+ // Compare against every other rule, but let only the earlier of two
55
+ // equivalent rules survive, or both would absorb each other.
56
+ const covering = rules.find((other, otherIndex) => otherIndex !== index &&
57
+ (otherIndex < index || !covers(rule, other, budget)) &&
58
+ covers(other, rule, budget));
59
+ if (covering) {
60
+ notes.push({
61
+ reason: `already covered by ${covering.source}`,
62
+ source: rule.source,
63
+ });
64
+ continue;
65
+ }
66
+ kept.push(absorbPatterns(rule, notes, budget));
67
+ }
68
+ return { notes, rules: kept };
69
+ }
70
+ /**
71
+ * The rules of an excluding set — `blacklist`, `disabledSites` — with
72
+ * everything that can never fire removed.
73
+ *
74
+ * A page has to pass every including set to reach the result at all, so an
75
+ * entry excluding pages none of them lets through excludes nothing. This is
76
+ * the bulk of what makes a generated query long: a config guards checkout,
77
+ * account and login while `enabledSites` only ever allowed the homepage and
78
+ * product pages. Configs write those guards as entries of a single rule, so
79
+ * the reduction works per entry and drops the rule only once a condition has
80
+ * no entry left that could match.
81
+ *
82
+ * @param reachable - The including sets, each a disjunction, all of which have
83
+ * to hold. An empty set means "everything" and is left out by the caller.
84
+ */
85
+ export function dropUnreachableRules(rules, reachable, budget = UNLIMITED) {
86
+ const notes = [];
87
+ const kept = [];
88
+ const allowed = reachable.map((set) => ({
89
+ blocks: blockedSubjects(set),
90
+ rules: set,
91
+ source: sourceOf(set),
92
+ }));
93
+ for (const [index, rule] of rules.entries()) {
94
+ if (budget.exhausted()) {
95
+ notes.push(STOPPED_EARLY);
96
+ kept.push(...rules.slice(index));
97
+ break;
98
+ }
99
+ const narrowed = narrowToReachable(rule, allowed, notes, budget);
100
+ if (narrowed) {
101
+ kept.push(narrowed);
102
+ }
103
+ }
104
+ return { notes, rules: kept };
105
+ }
106
+ /**
107
+ * Rules of an including set that an excluding rule covers completely — pages
108
+ * the config means to accelerate but never will.
109
+ *
110
+ * Unlike the reductions above this changes nothing about the query: it is a
111
+ * finding about the config, and the caller reports it.
112
+ */
113
+ export function findShadowedRules(included, excluding, budget = UNLIMITED) {
114
+ const notes = [];
115
+ for (const rule of included) {
116
+ if (budget.exhausted()) {
117
+ break;
118
+ }
119
+ const shadowing = excluding.find((other) => covers(other, rule, budget));
120
+ if (shadowing) {
121
+ notes.push({
122
+ reason: `is completely excluded again by ${shadowing.source}, so it never applies`,
123
+ source: rule.source,
124
+ });
125
+ }
126
+ }
127
+ return notes;
128
+ }
129
+ /**
130
+ * The rule without the condition entries no reachable page can match, or
131
+ * `undefined` when a condition ends up with no entry at all — the rule then
132
+ * matches nothing.
133
+ */
134
+ function narrowToReachable(rule, allowed, notes, budget) {
135
+ const conditions = [];
136
+ for (const condition of rule.conditions) {
137
+ const subject = resolvedSubject(condition.subject, rule.ruleSetVersion);
138
+ const reasons = new Map();
139
+ const patterns = condition.patterns.filter((pattern) => {
140
+ if (budget.exhausted()) {
141
+ return true;
142
+ }
143
+ const blocking = blockingSet(pattern, condition, rule, subject, allowed, budget);
144
+ if (blocking) {
145
+ reasons.set(pattern, blocking);
146
+ }
147
+ return !blocking;
148
+ });
149
+ if (patterns.length === 0) {
150
+ notes.push({
151
+ reason: `matches no page ${[...new Set(reasons.values())].join(" or ")} lets through`,
152
+ source: rule.source,
153
+ });
154
+ return undefined;
155
+ }
156
+ for (const [pattern, source] of reasons) {
157
+ notes.push({
158
+ reason: `${literalOf(pattern)} matches no page ${source} lets through`,
159
+ source: `${rule.source}.${condition.subject}`,
160
+ });
161
+ }
162
+ conditions.push({ ...condition, patterns });
163
+ }
164
+ return { ...rule, conditions };
165
+ }
166
+ /**
167
+ * The including set that keeps every page this entry matches out, if there is
168
+ * one. A set does that when none of its rules leaves room for the entry.
169
+ */
170
+ function blockingSet(pattern, condition, rule, subject, allowed, budget) {
171
+ const candidates = allowed.filter((set) => set.blocks.has(subject));
172
+ if (candidates.length === 0) {
173
+ return undefined;
174
+ }
175
+ const language = languageOf(pattern, condition, rule, "atLeast");
176
+ if (!language) {
177
+ return undefined;
178
+ }
179
+ const blocking = candidates.find((set) => set.rules.every((allowedRule) => {
180
+ // A rule matching nothing keeps everything out.
181
+ if (allowedRule.matchesNothing) {
182
+ return true;
183
+ }
184
+ // A rule whose patterns have no automaton might leave room.
185
+ const allowedLanguage = languagesOf(allowedRule, "atLeast")?.get(subject);
186
+ return (!!allowedLanguage &&
187
+ budget.spend() &&
188
+ languagesDisjoint(language, allowedLanguage) === true);
189
+ }));
190
+ return blocking?.source;
191
+ }
192
+ /** The rule with the condition entries a sibling entry already covers removed. */
193
+ function absorbPatterns(rule, notes, budget) {
194
+ const conditions = rule.conditions.map((condition) => {
195
+ if (condition.patterns.length < 2) {
196
+ return condition;
197
+ }
198
+ // The same entry is offered as the covering one to every other entry, so
199
+ // its complement is built once.
200
+ const outsides = new Map();
201
+ const outsideFor = (pattern) => {
202
+ if (!outsides.has(pattern)) {
203
+ const language = languageOf(pattern, condition, rule, "atMost");
204
+ outsides.set(pattern, language && outsideOf(language));
205
+ }
206
+ return outsides.get(pattern);
207
+ };
208
+ const patterns = condition.patterns.filter((pattern, index) => {
209
+ if (budget.exhausted()) {
210
+ return true;
211
+ }
212
+ const covering = condition.patterns.find((other, otherIndex) => otherIndex === index
213
+ ? false
214
+ : coversPattern(other, pattern, condition, rule, otherIndex < index, outsideFor, budget));
215
+ if (covering) {
216
+ notes.push({
217
+ reason: `${literalOf(pattern)} is already covered by ${literalOf(covering)}`,
218
+ source: `${rule.source}.${condition.subject}`,
219
+ });
220
+ }
221
+ return !covering;
222
+ });
223
+ return { ...condition, patterns };
224
+ });
225
+ return { ...rule, conditions };
226
+ }
227
+ /**
228
+ * True when every subject the entry matches is matched by `covering` too.
229
+ *
230
+ * @param mayAbsorbEquivalent - Whether `covering` is allowed to absorb an
231
+ * entry equivalent to it, which only the earlier of the two may do.
232
+ */
233
+ function coversPattern(covering, pattern, condition, rule, mayAbsorbEquivalent, outsideFor, budget) {
234
+ if (!budget.spend()) {
235
+ return false;
236
+ }
237
+ const beyondCovering = outsideFor(covering);
238
+ const narrower = languageOf(pattern, condition, rule, "atLeast");
239
+ if (!beyondCovering ||
240
+ !narrower ||
241
+ isInside(narrower, beyondCovering) !== true) {
242
+ return false;
243
+ }
244
+ if (mayAbsorbEquivalent) {
245
+ return true;
246
+ }
247
+ const beyondPattern = outsideFor(pattern);
248
+ const coveringAtLeast = languageOf(covering, condition, rule, "atLeast");
249
+ const equivalent = !!beyondPattern &&
250
+ !!coveringAtLeast &&
251
+ isInside(coveringAtLeast, beyondPattern) === true;
252
+ return !equivalent;
253
+ }
254
+ /** The language of a single condition entry. */
255
+ function languageOf(pattern, condition, rule, bound) {
256
+ return conditionLanguage({ patterns: [pattern], subject: condition.subject }, rule.ruleSetVersion, bound);
257
+ }
258
+ /** True when every page `rule` matches is matched by `covering` as well. */
259
+ function covers(covering, rule, budget) {
260
+ if (covering.matchesNothing || rule.matchesNothing) {
261
+ return false;
262
+ }
263
+ if (!coversContentTypes(covering, rule)) {
264
+ return false;
265
+ }
266
+ // A subject the covering rule constrains and the other one does not leaves
267
+ // that one wider. Checking the subjects first keeps the automata out of the
268
+ // comparison of two rules that talk about different parts of the URL.
269
+ const constrained = subjectsOf(rule);
270
+ if (![...subjectsOf(covering)].every((subject) => constrained.has(subject))) {
271
+ return false;
272
+ }
273
+ // Everything past here builds automata, so it costs a comparison.
274
+ if (!budget.spend()) {
275
+ return false;
276
+ }
277
+ // Containment holds when everything the rule can match — at least — stays
278
+ // inside what the covering rule matches at most.
279
+ const languages = languagesOf(rule, "atLeast");
280
+ const outside = outsideLanguagesOf(covering);
281
+ if (!languages || !outside) {
282
+ return false;
283
+ }
284
+ // Every subject the covering rule constrains has to be constrained at least
285
+ // as narrowly by the other one. A subject it does not constrain is free,
286
+ // which can only make it wider.
287
+ for (const [subject, beyond] of outside) {
288
+ const narrower = languages.get(subject);
289
+ if (!narrower || !beyond || isInside(narrower, beyond) !== true) {
290
+ return false;
291
+ }
292
+ }
293
+ return true;
294
+ }
295
+ /**
296
+ * The language of every subject a rule constrains, or `undefined` when one of
297
+ * them has no automaton — in which case the rule takes part in no reduction.
298
+ */
299
+ function languagesOf(rule, bound) {
300
+ const cached = ruleCache.get(rule);
301
+ if (cached?.has(bound)) {
302
+ return cached.get(bound);
303
+ }
304
+ const languages = buildLanguagesOf(rule, bound);
305
+ if (cached) {
306
+ cached.set(bound, languages);
307
+ }
308
+ else {
309
+ ruleCache.set(rule, new Map([[bound, languages]]));
310
+ }
311
+ return languages;
312
+ }
313
+ /** Languages already collected, per rule. */
314
+ const ruleCache = new WeakMap();
315
+ /** Complements already built, per rule. */
316
+ const outsideCache = new WeakMap();
317
+ /**
318
+ * What every subject of a rule does *not* match, built once per rule: the same
319
+ * rule is offered as the covering one to every other rule of its set.
320
+ */
321
+ function outsideLanguagesOf(rule) {
322
+ if (outsideCache.has(rule)) {
323
+ return outsideCache.get(rule);
324
+ }
325
+ const languages = languagesOf(rule, "atMost");
326
+ const outside = languages
327
+ ? new Map([...languages].map(([subject, language]) => [
328
+ subject,
329
+ outsideOf(language),
330
+ ]))
331
+ : undefined;
332
+ outsideCache.set(rule, outside);
333
+ return outside;
334
+ }
335
+ function buildLanguagesOf(rule, bound) {
336
+ const languages = new Map();
337
+ for (const condition of rule.conditions) {
338
+ const language = conditionLanguage(condition, rule.ruleSetVersion, bound);
339
+ if (!language) {
340
+ return undefined;
341
+ }
342
+ languages.set(resolvedSubject(condition.subject, rule.ruleSetVersion), language);
343
+ }
344
+ return languages;
345
+ }
346
+ /** True when `covering` applies wherever `rule` does, content-type-wise. */
347
+ function coversContentTypes(covering, rule) {
348
+ if (!covering.contentTypes) {
349
+ return true;
350
+ }
351
+ return (rule.contentTypes?.every((type) => covering.contentTypes?.includes(type)) ??
352
+ false);
353
+ }
354
+ /** The subjects a rule constrains. */
355
+ function subjectsOf(rule) {
356
+ return new Set(rule.conditions.map(({ subject }) => resolvedSubject(subject, rule.ruleSetVersion)));
357
+ }
358
+ /**
359
+ * The subjects on which a set of allowed rules can keep something out: those
360
+ * every one of its rules constrains.
361
+ */
362
+ function blockedSubjects(rules) {
363
+ let blocked = new Set(SUBJECTS);
364
+ for (const rule of rules) {
365
+ // A rule matching nothing keeps everything out, on every subject.
366
+ if (rule.matchesNothing) {
367
+ continue;
368
+ }
369
+ const constrained = subjectsOf(rule);
370
+ blocked = new Set([...blocked].filter((subject) => constrained.has(subject)));
371
+ }
372
+ return blocked;
373
+ }
374
+ /** The rule set a group of rules came from, for a readable reason. */
375
+ function sourceOf(rules) {
376
+ const [first] = rules;
377
+ return first ? first.source.replace(/\[\d+].*$/, "") : "the enabled pages";
378
+ }
379
+ function literalOf(pattern) {
380
+ return pattern.kind === "regexp"
381
+ ? String(pattern.value)
382
+ : `"${pattern.value}"`;
383
+ }
@@ -0,0 +1,43 @@
1
+ import { NormalizedRule } from "./rule-model.js";
2
+ export interface RelevanceContext {
3
+ /**
4
+ * Content types a row of the queried relation can have. A page impression
5
+ * is a document, so a rule written for images or scripts can never apply.
6
+ */
7
+ readonly contentTypes?: readonly string[];
8
+ /**
9
+ * Hosts the query is already restricted to — the app's origins. A rule
10
+ * whose host condition matches none of them can never fire. Leave empty
11
+ * when the hosts are not known, and the check is skipped.
12
+ */
13
+ readonly hosts?: readonly string[];
14
+ }
15
+ export interface RuleNote {
16
+ readonly reason: string;
17
+ /** Where the rule sits in the config, e.g. `blacklist[3]`. */
18
+ readonly source: string;
19
+ }
20
+ export interface RelevanceResult {
21
+ /**
22
+ * Rules that survive but do not mean exactly what the config means — they
23
+ * carry a criterion outside the URL that the query cannot express, so the
24
+ * emitted filter is wider than the rule.
25
+ */
26
+ readonly approximated: RuleNote[];
27
+ /** Rules that can never apply to a row of this query. */
28
+ readonly dropped: RuleNote[];
29
+ readonly rules: NormalizedRule[];
30
+ }
31
+ /**
32
+ * Rules the query cannot possibly be affected by, removed.
33
+ *
34
+ * This is the first half of ruling the rule sets against each other: not rule
35
+ * against rule yet, but every rule against the frame the query already sets —
36
+ * documents, on this app's origins. A `contentType: ['image']` blacklist entry
37
+ * or a rule for a third-party host says nothing about a page impression, and
38
+ * keeping it only narrows the result for no reason.
39
+ *
40
+ * Nothing is dropped silently: every decision comes back in `dropped` or
41
+ * `approximated` so it can be reported.
42
+ */
43
+ export declare function keepRelevantRules(rules: readonly NormalizedRule[], context?: RelevanceContext): RelevanceResult;