@rogatio/cli 6.3.0 → 6.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -395,6 +395,28 @@
395
395
  margin: 0;
396
396
  min-width: 0;
397
397
  }
398
+ .rogatio-editor [data-rename-controls] {
399
+ display: flex;
400
+ flex: 1 1 16rem;
401
+ align-items: center;
402
+ gap: 0.35rem;
403
+ min-width: 0;
404
+ }
405
+ .rogatio-editor [data-rename-controls] input {
406
+ flex: 1 1 auto;
407
+ width: auto;
408
+ min-width: 0;
409
+ max-width: 32rem;
410
+ }
411
+ .rogatio-editor button[data-icon] {
412
+ flex: none;
413
+ width: 1.8rem;
414
+ min-height: 1.8rem;
415
+ padding: 0.15rem;
416
+ font-family: var(--rogatio-font-mono);
417
+ font-size: 0.9rem;
418
+ line-height: 1;
419
+ }
398
420
  .rogatio-editor [data-group-list] {
399
421
  list-style: none;
400
422
  margin: 0;
@@ -413,10 +435,6 @@
413
435
  background: var(--rogatio-card-group);
414
436
  padding: 0.65rem 0.85rem;
415
437
  }
416
- .rogatio-editor [data-group-card] {
417
- background: var(--rogatio-card-group);
418
- border-color: rgba(148, 163, 184, 0.2);
419
- }
420
438
  .rogatio-editor [data-rules-section] {
421
439
  display: grid;
422
440
  gap: 0.85rem;
@@ -185,6 +185,56 @@ var FORBIDDEN_RESPONSE_HEADERS = Object.freeze([
185
185
  "via"
186
186
  ]);
187
187
  var FORBIDDEN_REQUEST_PREFIXES = Object.freeze(["proxy-", "sec-"]);
188
+ var NON_ALPHANUMERIC = /[^a-z0-9]+/u;
189
+ var WHITESPACE_RUN = /\s+/gu;
190
+ var FALLBACK_PREFIX = Object.freeze({
191
+ group: "Group",
192
+ rule: "Rule"
193
+ });
194
+ function normalizeNameKey(name) {
195
+ if (typeof name !== "string") return "";
196
+ return name.trim().replace(WHITESPACE_RUN, " ").toLowerCase();
197
+ }
198
+ function pascalCase(value) {
199
+ const tokens = value.toLowerCase().split(NON_ALPHANUMERIC);
200
+ let result = "";
201
+ for (const token of tokens) {
202
+ const first = token.charAt(0);
203
+ if (first.length === 0) continue;
204
+ result += first.toUpperCase() + token.slice(1);
205
+ }
206
+ return result;
207
+ }
208
+ function allocate(stem, reserved, maxLength) {
209
+ for (let suffix = 1; ; suffix += 1) {
210
+ const suffixText = suffix === 1 ? "" : String(suffix);
211
+ const room = Math.max(1, maxLength - suffixText.length);
212
+ const candidate = `${stem.slice(0, room)}${suffixText}`;
213
+ if (reserved.has(candidate)) continue;
214
+ reserved.add(candidate);
215
+ return candidate;
216
+ }
217
+ }
218
+ function deriveEntityId(name, kind, reserved) {
219
+ const derived = pascalCase(typeof name === "string" ? name : "");
220
+ return allocate(
221
+ derived.length > 0 ? derived : FALLBACK_PREFIX[kind],
222
+ reserved,
223
+ LIMITS.maxIdLength
224
+ );
225
+ }
226
+ function uniqueName(base, reserved) {
227
+ const label = typeof base === "string" ? base : "";
228
+ for (let suffix = 1; ; suffix += 1) {
229
+ const suffixText = suffix === 1 ? "" : ` ${suffix}`;
230
+ const room = Math.max(1, LIMITS.maxLabelLength - suffixText.length);
231
+ const candidate = `${label.slice(0, room)}${suffixText}`;
232
+ const key = normalizeNameKey(candidate);
233
+ if (reserved.has(key)) continue;
234
+ reserved.add(key);
235
+ return candidate;
236
+ }
237
+ }
188
238
  var RESOURCE_TYPES = Object.freeze([
189
239
  "main_frame",
190
240
  "sub_frame",
@@ -2131,6 +2181,14 @@ var EditorControllerImpl = class {
2131
2181
  aiAssistInFlight = false;
2132
2182
  aiRepairTargets = [];
2133
2183
  migrationNoticesDismissed = false;
2184
+ /**
2185
+ * The entity whose name is being edited in place, if any. The buffer lives
2186
+ * here rather than in the DOM because every render rebuilds the heading, and
2187
+ * the draft is deliberately untouched until an explicit commit.
2188
+ */
2189
+ renameTarget;
2190
+ /** Re-entrancy guard: committing a rename renders, which asks again. */
2191
+ renamingInProgress = false;
2134
2192
  constructor(options, initial, extensions) {
2135
2193
  this.root = options.root;
2136
2194
  this.document = options.root.ownerDocument;
@@ -2278,6 +2336,10 @@ var EditorControllerImpl = class {
2278
2336
  if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) {
2279
2337
  return;
2280
2338
  }
2339
+ if (target.dataset.renameInput !== void 0) {
2340
+ if (this.renameTarget) this.renameTarget.value = target.value;
2341
+ return;
2342
+ }
2281
2343
  if (target.dataset.search !== void 0) {
2282
2344
  this.searchQuery = target.value;
2283
2345
  this.render();
@@ -2345,14 +2407,36 @@ var EditorControllerImpl = class {
2345
2407
  };
2346
2408
  handleSubmit = (event) => {
2347
2409
  event.preventDefault();
2410
+ if (this.renameTarget && this.renameInputFocused()) {
2411
+ this.commitRename();
2412
+ return;
2413
+ }
2348
2414
  this.dispatchCommand("save", this.form);
2349
2415
  };
2416
+ renameInputFocused() {
2417
+ const active = this.document.activeElement;
2418
+ return active instanceof HTMLElement && active.dataset.renameInput !== void 0;
2419
+ }
2350
2420
  handleKeydown = (event) => {
2351
2421
  if (event.key === "Escape" && this.confirmation) {
2352
2422
  event.preventDefault();
2353
2423
  this.confirmation = void 0;
2354
2424
  this.statusMessage = "No changes were discarded.";
2355
2425
  this.render();
2426
+ return;
2427
+ }
2428
+ const target = event.target;
2429
+ const inRenameInput = target instanceof HTMLElement && target.dataset.renameInput !== void 0;
2430
+ if (!inRenameInput) return;
2431
+ if (this.composing || event.isComposing) return;
2432
+ if (event.key === "Enter") {
2433
+ event.preventDefault();
2434
+ this.commitRename();
2435
+ return;
2436
+ }
2437
+ if (event.key === "Escape") {
2438
+ event.preventDefault();
2439
+ this.cancelRename();
2356
2440
  }
2357
2441
  };
2358
2442
  handleCompositionStart = () => {
@@ -2449,6 +2533,31 @@ var EditorControllerImpl = class {
2449
2533
  if (groupId && ruleId) this.copyRule(groupId, ruleId);
2450
2534
  return;
2451
2535
  }
2536
+ if (command === "rename-entity") {
2537
+ const groupId = element.dataset.groupId;
2538
+ if (!groupId) return;
2539
+ const ruleId = element.dataset.ruleId;
2540
+ this.openRename(
2541
+ element.dataset.renameKind === "rule" ? "rule" : "group",
2542
+ groupId,
2543
+ ruleId
2544
+ );
2545
+ return;
2546
+ }
2547
+ if (command === "commit-rename") {
2548
+ this.commitRename();
2549
+ return;
2550
+ }
2551
+ if (command === "cancel-rename") {
2552
+ this.cancelRename();
2553
+ return;
2554
+ }
2555
+ if (command === "repair-id") {
2556
+ const path = element.dataset.repairPath;
2557
+ if (!path) return;
2558
+ this.repairEntityId(path);
2559
+ return;
2560
+ }
2452
2561
  if (command === "dismiss-migration-notices") {
2453
2562
  void this.dismissMigrationNotices();
2454
2563
  return;
@@ -2683,15 +2792,24 @@ var EditorControllerImpl = class {
2683
2792
  }
2684
2793
  return ids;
2685
2794
  }
2686
- allocateId(prefix, reserved) {
2687
- let candidate = prefix;
2688
- let suffix = 2;
2689
- while (reserved.has(candidate)) candidate = `${prefix}-${suffix++}`;
2690
- reserved.add(candidate);
2691
- return candidate;
2692
- }
2693
- nextId(prefix) {
2694
- return this.allocateId(prefix, this.allIds());
2795
+ /**
2796
+ * Every name currently in the project, keyed for uniqueness comparison. Names
2797
+ * are unique per project across groups and rules together, so a new entity
2798
+ * must claim a free one or the project it produces is invalid.
2799
+ */
2800
+ allNameKeys() {
2801
+ const reserved = /* @__PURE__ */ new Set();
2802
+ for (const group of this.draft.groups) {
2803
+ if (typeof group.name === "string") {
2804
+ reserved.add(normalizeNameKey(group.name));
2805
+ }
2806
+ for (const rule of group.rules) {
2807
+ if (typeof rule.name === "string") {
2808
+ reserved.add(normalizeNameKey(rule.name));
2809
+ }
2810
+ }
2811
+ }
2812
+ return reserved;
2695
2813
  }
2696
2814
  copyLabel(name) {
2697
2815
  const base = typeof name === "string" ? name : "";
@@ -2703,42 +2821,35 @@ var EditorControllerImpl = class {
2703
2821
  }
2704
2822
  addGroup() {
2705
2823
  if (this.saving) return;
2706
- const groupId = this.nextId("group-new");
2824
+ const name = uniqueName("New group", this.allNameKeys());
2825
+ const groupId = deriveEntityId(name, "group", this.allIds());
2707
2826
  this.draft.groups.push({
2708
2827
  id: groupId,
2709
- name: "New group",
2828
+ name,
2710
2829
  rules: []
2711
2830
  });
2712
2831
  this.markChanged();
2713
2832
  this.route = { kind: "group", groupId };
2714
- this.focusRequest = pointer("groups", this.draft.groups.length - 1, "name");
2715
2833
  this.statusMessage = "Group added.";
2716
- this.render();
2834
+ this.openRename("group", groupId);
2717
2835
  }
2718
2836
  addRule(groupId) {
2719
2837
  if (this.saving) return;
2720
2838
  const group = this.groupById(groupId);
2721
2839
  if (!group) return;
2722
- const ruleId = this.nextId("rule-new");
2840
+ const name = uniqueName("New rule", this.allNameKeys());
2841
+ const ruleId = deriveEntityId(name, "rule", this.allIds());
2723
2842
  group.rules.push({
2724
2843
  id: ruleId,
2725
- name: "New rule",
2844
+ name,
2726
2845
  source: { key: "url", operator: "regex", value: "" },
2727
2846
  resourceTypes: ["main_frame"],
2728
2847
  priority: 100
2729
2848
  });
2730
2849
  this.markChanged();
2731
2850
  this.route = { kind: "group", groupId };
2732
- const groupIndex = this.groupIndex(groupId);
2733
- this.focusRequest = pointer(
2734
- "groups",
2735
- groupIndex,
2736
- "rules",
2737
- group.rules.length - 1,
2738
- "name"
2739
- );
2740
2851
  this.statusMessage = "Rule added.";
2741
- this.render();
2852
+ this.openRename("rule", groupId, ruleId);
2742
2853
  }
2743
2854
  copyRule(groupId, ruleId) {
2744
2855
  if (this.saving) return;
@@ -2753,21 +2864,16 @@ var EditorControllerImpl = class {
2753
2864
  return;
2754
2865
  }
2755
2866
  const copied = snapshot.value;
2756
- copied.id = this.nextId("rule-new");
2757
- copied.name = this.copyLabel(group.rules[sourceIndex].name);
2867
+ copied.name = uniqueName(
2868
+ this.copyLabel(group.rules[sourceIndex].name),
2869
+ this.allNameKeys()
2870
+ );
2871
+ copied.id = deriveEntityId(copied.name, "rule", this.allIds());
2758
2872
  group.rules.splice(sourceIndex + 1, 0, copied);
2759
2873
  this.markChanged();
2760
2874
  this.route = { kind: "group", groupId };
2761
- const groupIndex = this.groupIndex(groupId);
2762
- this.focusRequest = pointer(
2763
- "groups",
2764
- groupIndex,
2765
- "rules",
2766
- sourceIndex + 1,
2767
- "name"
2768
- );
2769
2875
  this.statusMessage = "Rule copied.";
2770
- this.render();
2876
+ this.openRename("rule", groupId, String(copied.id));
2771
2877
  }
2772
2878
  copyGroup(groupId) {
2773
2879
  if (this.saving) return;
@@ -2786,18 +2892,20 @@ var EditorControllerImpl = class {
2786
2892
  this.render();
2787
2893
  return;
2788
2894
  }
2789
- const reserved = this.allIds();
2790
- copied.id = this.allocateId("group-new", reserved);
2791
- copied.name = this.copyLabel(source.name);
2895
+ const names = this.allNameKeys();
2896
+ const ids = this.allIds();
2897
+ copied.name = uniqueName(this.copyLabel(source.name), names);
2898
+ copied.id = deriveEntityId(copied.name, "group", ids);
2792
2899
  for (const rule of copied.rules) {
2793
- rule.id = this.allocateId("rule-new", reserved);
2900
+ if (typeof rule.name === "string") {
2901
+ rule.name = uniqueName(rule.name, names);
2902
+ }
2903
+ rule.id = deriveEntityId(rule.name, "rule", ids);
2794
2904
  }
2795
2905
  this.draft.groups.splice(sourceIndex + 1, 0, copied);
2796
2906
  this.markChanged();
2797
- this.route = { kind: "group", groupId: String(copied.id) };
2798
- this.focusRequest = pointer("groups", sourceIndex + 1, "name");
2799
2907
  this.statusMessage = "Group copied.";
2800
- this.render();
2908
+ this.openRename("group", String(copied.id));
2801
2909
  }
2802
2910
  moveGroup(groupId, delta) {
2803
2911
  if (this.saving) return;
@@ -2855,6 +2963,9 @@ var EditorControllerImpl = class {
2855
2963
  if (!this.confirmation || this.confirmation.kind === "cancel") return;
2856
2964
  const confirmation = this.confirmation;
2857
2965
  this.confirmation = void 0;
2966
+ this.closeRenameFor(
2967
+ confirmation.kind === "remove-group" ? { groupId: confirmation.groupId, ruleId: void 0 } : { groupId: confirmation.groupId, ruleId: confirmation.ruleId }
2968
+ );
2858
2969
  if (confirmation.kind === "remove-group") {
2859
2970
  const index = this.groupIndex(confirmation.groupId);
2860
2971
  if (index < 0) return;
@@ -2901,6 +3012,7 @@ var EditorControllerImpl = class {
2901
3012
  this.errors = [];
2902
3013
  this.conversionDiagnostics.clear();
2903
3014
  this.confirmation = void 0;
3015
+ this.renameTarget = void 0;
2904
3016
  this.statusMessage = "Changes discarded.";
2905
3017
  try {
2906
3018
  this.options.onCancel?.();
@@ -2960,13 +3072,14 @@ var EditorControllerImpl = class {
2960
3072
  applyAIProposal(proposal) {
2961
3073
  const repairs = [...this.aiRepairTargets];
2962
3074
  this.aiRepairTargets = [];
3075
+ let lastApplied;
2963
3076
  for (const ruleProposal of proposal.rules) {
2964
3077
  let group = this.groupById(ruleProposal.groupId);
2965
3078
  if (!group) {
2966
3079
  const groupId = ruleProposal.groupId;
2967
3080
  this.draft.groups.push({
2968
3081
  id: groupId,
2969
- name: "AI Group",
3082
+ name: uniqueName("AI Group", this.allNameKeys()),
2970
3083
  rules: []
2971
3084
  });
2972
3085
  group = this.groupById(groupId);
@@ -2977,7 +3090,11 @@ var EditorControllerImpl = class {
2977
3090
  (target) => target.groupId === ruleProposal.groupId && rules.some((existing) => String(existing.id) === target.ruleId)
2978
3091
  );
2979
3092
  let repairIndex = -1;
2980
- let ruleId = this.nextId("rule-ai");
3093
+ const proposedName = uniqueName(
3094
+ ruleProposal.name,
3095
+ repair === void 0 ? this.allNameKeys() : this.reservedNameKeys(repair)
3096
+ );
3097
+ let ruleId = repair === void 0 ? deriveEntityId(proposedName, "rule", this.allIds()) : repair.ruleId;
2981
3098
  if (repair) {
2982
3099
  repairs.splice(repairs.indexOf(repair), 1);
2983
3100
  ruleId = repair.ruleId;
@@ -2987,7 +3104,7 @@ var EditorControllerImpl = class {
2987
3104
  }
2988
3105
  const rule = {
2989
3106
  id: ruleId,
2990
- name: ruleProposal.name,
3107
+ name: proposedName,
2991
3108
  source: {
2992
3109
  key: ruleProposal.source.key,
2993
3110
  operator: "regex",
@@ -3006,16 +3123,13 @@ var EditorControllerImpl = class {
3006
3123
  rules.push(rule);
3007
3124
  }
3008
3125
  this.markChanged();
3009
- const groupIdStr = String(group.id);
3010
- this.focusRequest = pointer(
3011
- "groups",
3012
- this.groupIndex(groupIdStr),
3013
- "rules",
3014
- repairIndex >= 0 ? repairIndex : rules.length - 1,
3015
- "name"
3016
- );
3126
+ lastApplied = { groupId: String(group.id), ruleId: String(rule.id) };
3017
3127
  }
3018
3128
  this.statusMessage = `Applied ${proposal.rules.length} rule${proposal.rules.length === 1 ? "" : "s"} from AI.`;
3129
+ if (lastApplied) {
3130
+ this.openRename("rule", lastApplied.groupId, lastApplied.ruleId);
3131
+ return;
3132
+ }
3019
3133
  this.render();
3020
3134
  }
3021
3135
  /**
@@ -3259,7 +3373,7 @@ var EditorControllerImpl = class {
3259
3373
  const ruleHeader = this.document.createElement("div");
3260
3374
  ruleHeader.dataset.testRuleHeader = "true";
3261
3375
  const ruleName = this.document.createElement("strong");
3262
- ruleName.textContent = `${rule.groupId}/${rule.ruleId}`;
3376
+ ruleName.textContent = this.testRuleLabel(rule.groupId, rule.ruleId);
3263
3377
  const ruleBadge = this.document.createElement("span");
3264
3378
  ruleBadge.dataset.testBadge = "true";
3265
3379
  ruleBadge.dataset.variant = rule.matched ? "matched" : "unmatched";
@@ -3295,6 +3409,20 @@ var EditorControllerImpl = class {
3295
3409
  resultsSection.append(card);
3296
3410
  }
3297
3411
  }
3412
+ /**
3413
+ * A test-result row is identified by the names the user actually sees. The dry
3414
+ * result carries ids only, and the draft the editor handed over is the source
3415
+ * of truth for both, so no contract changes. A rule that is no longer in the
3416
+ * draft falls back to its id rather than rendering a blank row.
3417
+ */
3418
+ testRuleLabel(groupId, ruleId) {
3419
+ const group = this.groupById(groupId);
3420
+ if (!group) return `${groupId}/${ruleId}`;
3421
+ const groupName = displayName(group.name, "Unnamed group");
3422
+ const rule = this.ruleById(groupId, ruleId);
3423
+ if (!rule) return `${groupName} / ${ruleId}`;
3424
+ return `${groupName} / ${displayName(rule.name, "Unnamed rule")}`;
3425
+ }
3298
3426
  navigate(route, groupId) {
3299
3427
  if (route === "project") {
3300
3428
  this.route = { kind: "project" };
@@ -3305,10 +3433,30 @@ var EditorControllerImpl = class {
3305
3433
  } else {
3306
3434
  return;
3307
3435
  }
3436
+ this.closeRenameOnLeave();
3308
3437
  this.testRequestId += 1;
3309
3438
  this.statusMessage = "";
3310
3439
  this.render();
3311
3440
  }
3441
+ /**
3442
+ * An uncommitted rename belongs to the entity it was started on. Leaving that
3443
+ * entity, removing it, or discarding the draft closes the editor, so no buffer
3444
+ * survives against a heading that is no longer on screen.
3445
+ */
3446
+ closeRenameOnLeave() {
3447
+ const target = this.renameTarget;
3448
+ if (!target) return;
3449
+ const onGroup = this.route.kind === "group" && this.route.groupId === target.groupId;
3450
+ if (!onGroup) this.renameTarget = void 0;
3451
+ }
3452
+ /** Close an open rename when the entity it belongs to goes away. */
3453
+ closeRenameFor(entity) {
3454
+ const target = this.renameTarget;
3455
+ if (!target) return;
3456
+ if (target.groupId === entity.groupId && target.ruleId === entity.ruleId) {
3457
+ this.renameTarget = void 0;
3458
+ }
3459
+ }
3312
3460
  navigateToGroup(groupId) {
3313
3461
  const groupIds = new Set(
3314
3462
  this.draft.groups.map((group) => typeof group.id === "string" ? group.id : "").filter((id) => id.length > 0)
@@ -3334,11 +3482,22 @@ var EditorControllerImpl = class {
3334
3482
  );
3335
3483
  for (const button of buttons) {
3336
3484
  const groupId = button.dataset.groupId ?? "";
3337
- const headingName = button.parentElement?.querySelector("h2")?.textContent?.trim();
3338
- const groupName = headingName && headingName.length > 0 ? headingName : displayName(this.groupById(groupId)?.name, "Unnamed group");
3485
+ const draftGroup = this.draftGroupForSavedId(groupId);
3486
+ const groupName = displayName(
3487
+ draftGroup?.name,
3488
+ button.parentElement?.querySelector("h2")?.textContent?.trim() ?? ""
3489
+ );
3339
3490
  this.applyGroupEnablementLabel(button, enabled.has(groupId), groupName);
3340
3491
  }
3341
3492
  }
3493
+ /** The draft group bound to a committed id, through the index-based binding. */
3494
+ draftGroupForSavedId(savedId) {
3495
+ if (savedId.length === 0) return void 0;
3496
+ for (const group of this.draft.groups) {
3497
+ if (this.savedGroupId(group) === savedId) return group;
3498
+ }
3499
+ return void 0;
3500
+ }
3342
3501
  /**
3343
3502
  * Scroll a rendered rule card into view and focus it. Explicit `behavior`
3344
3503
  * overrides the stylesheet's `scroll-behavior`, so the reduced-motion
@@ -3400,10 +3559,14 @@ var EditorControllerImpl = class {
3400
3559
  return;
3401
3560
  }
3402
3561
  const groupIndex = arrayIndex(segments[1] ?? "");
3403
- const group = groupIndex === void 0 ? void 0 : this.draft.groups[groupIndex];
3562
+ if (groupIndex === void 0) return;
3563
+ const group = this.draft.groups[groupIndex];
3404
3564
  if (!group || typeof group.id !== "string") return;
3405
3565
  this.route = { kind: "group", groupId: group.id };
3406
- this.focusRequest = path;
3566
+ const last = segments[segments.length - 1];
3567
+ const ruleIndex = arrayIndex(segments[3] ?? "");
3568
+ const entityPath = last === "name" ? path : segments.length === 2 ? pointer("groups", groupIndex, "name") : segments.length === 4 && ruleIndex !== void 0 ? pointer("groups", groupIndex, "rules", ruleIndex, "name") : path;
3569
+ this.focusRequest = entityPath;
3407
3570
  this.render();
3408
3571
  }
3409
3572
  currentGroupId() {
@@ -3433,6 +3596,7 @@ var EditorControllerImpl = class {
3433
3596
  }
3434
3597
  render() {
3435
3598
  if (this.destroyed) return;
3599
+ this.composing = false;
3436
3600
  this.previousFocus = this.captureFocus();
3437
3601
  this.cleanupExtensions();
3438
3602
  this.controlNumber = 0;
@@ -3678,7 +3842,6 @@ var EditorControllerImpl = class {
3678
3842
  const headingRow = this.document.createElement("div");
3679
3843
  headingRow.dataset.groupHeading = "true";
3680
3844
  const heading = this.document.createElement("h2");
3681
- heading.textContent = groupName;
3682
3845
  const copyGroup = this.createCommandButton(
3683
3846
  "Copy group",
3684
3847
  "copy-group",
@@ -3694,6 +3857,7 @@ var EditorControllerImpl = class {
3694
3857
  );
3695
3858
  const savedGroupId = this.savedGroupId(group);
3696
3859
  const enablement = this.options.groupEnablement;
3860
+ const commands = [];
3697
3861
  if (enablement && savedGroupId) {
3698
3862
  const enableButton = this.document.createElement("button");
3699
3863
  enableButton.type = "button";
@@ -3717,40 +3881,21 @@ var EditorControllerImpl = class {
3717
3881
  }
3718
3882
  );
3719
3883
  });
3720
- headingRow.append(heading, enableButton, copyGroup, removeGroup);
3721
- } else {
3722
- headingRow.append(heading, copyGroup, removeGroup);
3884
+ commands.push(enableButton);
3723
3885
  }
3886
+ commands.push(copyGroup, removeGroup);
3887
+ this.renderNameHeading({
3888
+ row: headingRow,
3889
+ heading,
3890
+ headingId: `${this.instanceId}-group-title-${groupIndex}`,
3891
+ kind: "group",
3892
+ groupId,
3893
+ namePath: pointer("groups", groupIndex, "name"),
3894
+ name: group.name,
3895
+ fallback: "Unnamed group",
3896
+ commands
3897
+ });
3724
3898
  this.form.append(headingRow);
3725
- const settings = this.document.createElement("fieldset");
3726
- settings.dataset.groupCard = "true";
3727
- const legend = this.document.createElement("legend");
3728
- legend.textContent = "Group details";
3729
- settings.append(legend);
3730
- const fields = this.document.createElement("div");
3731
- fields.dataset.editorFields = "true";
3732
- const id = this.document.createElement("input");
3733
- id.type = "text";
3734
- id.maxLength = 64;
3735
- id.value = safeText(group.id);
3736
- this.renderField(
3737
- fields,
3738
- "Group ID",
3739
- pointer("groups", groupIndex, "id"),
3740
- id
3741
- );
3742
- const name = this.document.createElement("input");
3743
- name.type = "text";
3744
- name.maxLength = 100;
3745
- name.value = safeText(group.name);
3746
- this.renderField(
3747
- fields,
3748
- "Group name",
3749
- pointer("groups", groupIndex, "name"),
3750
- name
3751
- );
3752
- settings.append(fields);
3753
- this.form.append(settings);
3754
3899
  const rulesSection = this.document.createElement("section");
3755
3900
  rulesSection.dataset.rulesSection = "true";
3756
3901
  const rulesHeadingRow = this.document.createElement("div");
@@ -3893,7 +4038,6 @@ var EditorControllerImpl = class {
3893
4038
  renderRule(group, rule, groupIndex, ruleIndex) {
3894
4039
  const groupId = safeText(group.id);
3895
4040
  const ruleId = safeText(rule.id);
3896
- const ruleName = displayName(rule.name, "Unnamed rule");
3897
4041
  const rulePath = pointer("groups", groupIndex, "rules", ruleIndex);
3898
4042
  const card = this.document.createElement("article");
3899
4043
  card.dataset.ruleCard = "true";
@@ -3903,9 +4047,8 @@ var EditorControllerImpl = class {
3903
4047
  const headingRow = this.document.createElement("div");
3904
4048
  headingRow.dataset.ruleHeading = "true";
3905
4049
  const heading = this.document.createElement("h3");
3906
- heading.id = `${this.instanceId}-rule-title-${groupIndex}-${ruleIndex}`;
3907
- heading.textContent = ruleName;
3908
- card.setAttribute("aria-labelledby", heading.id);
4050
+ const headingId = `${this.instanceId}-rule-title-${groupIndex}-${ruleIndex}`;
4051
+ card.setAttribute("aria-labelledby", headingId);
3909
4052
  const actions = this.document.createElement("div");
3910
4053
  actions.dataset.ruleActions = "true";
3911
4054
  actions.append(
@@ -3933,27 +4076,20 @@ var EditorControllerImpl = class {
3933
4076
  "danger"
3934
4077
  )
3935
4078
  );
3936
- headingRow.append(heading, actions);
4079
+ this.renderNameHeading({
4080
+ row: headingRow,
4081
+ heading,
4082
+ headingId,
4083
+ kind: "rule",
4084
+ groupId,
4085
+ ruleId,
4086
+ namePath: `${rulePath}/name`,
4087
+ name: rule.name,
4088
+ fallback: "Unnamed rule",
4089
+ commands: [actions]
4090
+ });
3937
4091
  card.append(headingRow);
3938
- const fields = this.document.createElement("fieldset");
3939
- const legend = this.document.createElement("legend");
3940
- legend.textContent = "Common rule matcher";
3941
- fields.append(legend);
3942
- const grid = this.document.createElement("div");
3943
- grid.dataset.editorFields = "true";
3944
- const id = this.document.createElement("input");
3945
- id.type = "text";
3946
- id.maxLength = 64;
3947
- id.value = safeText(rule.id);
3948
- this.renderField(grid, "Rule ID", `${rulePath}/id`, id);
3949
- const name = this.document.createElement("input");
3950
- name.type = "text";
3951
- name.maxLength = 100;
3952
- name.value = safeText(rule.name);
3953
- this.renderField(grid, "Rule name", `${rulePath}/name`, name);
3954
- fields.append(grid);
3955
- this.renderSource(fields, rule, rulePath, groupId, ruleId);
3956
- card.append(fields);
4092
+ this.renderSource(card, rule, rulePath, groupId, ruleId);
3957
4093
  this.renderResourceTypes(card, rule, rulePath);
3958
4094
  const matcherFields = this.document.createElement("fieldset");
3959
4095
  const matcherLegend = this.document.createElement("legend");
@@ -4359,6 +4495,299 @@ var EditorControllerImpl = class {
4359
4495
  this.controls.set(path, control);
4360
4496
  parent.append(field);
4361
4497
  }
4498
+ /**
4499
+ * Attach a control's diagnostics the same way `renderField` does, for a control
4500
+ * that is not wrapped in a labelled field — the inline name editor.
4501
+ */
4502
+ decorateWithErrors(control, path) {
4503
+ const errors = this.errors.filter((value) => value.path === path);
4504
+ if (errors.length === 0) {
4505
+ control.removeAttribute("aria-invalid");
4506
+ control.removeAttribute("aria-describedby");
4507
+ return;
4508
+ }
4509
+ control.setAttribute("aria-invalid", "true");
4510
+ const error = this.document.createElement("div");
4511
+ error.id = `${control.id}-error`;
4512
+ error.dataset.editorFieldError = "true";
4513
+ error.textContent = errors.map((value) => value.message).join(" ");
4514
+ control.setAttribute("aria-describedby", error.id);
4515
+ control.insertAdjacentElement("afterend", error);
4516
+ }
4517
+ /**
4518
+ * Render a group or rule heading together with its inline rename control.
4519
+ *
4520
+ * The heading element is always present, even while the inline editor is open,
4521
+ * because a rule card names itself through `aria-labelledby` and an `<input>`
4522
+ * has no text content to name it with. While editing, the heading is moved
4523
+ * into the visually-hidden state rather than removed, so the card keeps its
4524
+ * accessible name and the shared action buttons keep their per-entity context.
4525
+ */
4526
+ renderNameHeading(options) {
4527
+ const { row, heading, headingId, kind, namePath } = options;
4528
+ const entityName = displayName(options.name, options.fallback);
4529
+ const target = this.renameTargetFor(options);
4530
+ const editing = target !== void 0;
4531
+ const label = kind === "group" ? "group" : "rule";
4532
+ const entityLabel = `${label} ${entityName}`;
4533
+ heading.id = headingId;
4534
+ heading.textContent = entityName;
4535
+ if (editing) {
4536
+ heading.dataset.editorVisuallyHidden = "true";
4537
+ }
4538
+ row.append(heading);
4539
+ if (editing && target) {
4540
+ const wrap = this.document.createElement("div");
4541
+ wrap.dataset.renameControls = "true";
4542
+ const input = this.document.createElement("input");
4543
+ input.type = "text";
4544
+ input.maxLength = LIMITS.maxLabelLength;
4545
+ input.value = target.value;
4546
+ input.id = this.controlId(namePath);
4547
+ input.dataset.renameInput = "true";
4548
+ input.dataset.editorKey = namePath;
4549
+ input.disabled = this.saving;
4550
+ input.setAttribute(
4551
+ "aria-label",
4552
+ // Named for the action and the entity. The removed field labels must
4553
+ // not reappear as an accessible name.
4554
+ `Rename ${entityLabel}`
4555
+ );
4556
+ const save = this.createCommandButton(
4557
+ "Save name",
4558
+ "commit-rename",
4559
+ this.saving,
4560
+ { renameKind: kind, groupId: options.groupId, ruleId: options.ruleId },
4561
+ "primary"
4562
+ );
4563
+ save.setAttribute("aria-label", `Save ${entityLabel} name`);
4564
+ save.textContent = "\u2713";
4565
+ save.dataset.icon = "true";
4566
+ const cancel = this.createCommandButton(
4567
+ "Cancel rename",
4568
+ "cancel-rename",
4569
+ this.saving,
4570
+ { renameKind: kind, groupId: options.groupId, ruleId: options.ruleId }
4571
+ );
4572
+ cancel.setAttribute("aria-label", `Cancel renaming ${entityLabel}`);
4573
+ cancel.textContent = "\xD7";
4574
+ cancel.dataset.icon = "true";
4575
+ wrap.append(input, save, cancel);
4576
+ row.append(wrap);
4577
+ this.decorateWithErrors(input, namePath);
4578
+ this.controls.set(namePath, input);
4579
+ } else {
4580
+ const pencil = this.createCommandButton(
4581
+ `Rename ${label}`,
4582
+ "rename-entity",
4583
+ this.saving,
4584
+ { renameKind: kind, groupId: options.groupId, ruleId: options.ruleId }
4585
+ );
4586
+ pencil.setAttribute("aria-label", `Rename ${entityLabel}`);
4587
+ pencil.title = `Rename ${entityLabel}`;
4588
+ pencil.textContent = "\u270E";
4589
+ pencil.dataset.icon = "true";
4590
+ row.append(pencil);
4591
+ }
4592
+ for (const command of options.commands) row.append(command);
4593
+ }
4594
+ /**
4595
+ * The rename target for an entity, when it should be open.
4596
+ *
4597
+ * It is open when the entity is already being renamed, or when a focus request
4598
+ * names its `/name` path. The second case is what makes add, copy, move, AI
4599
+ * apply, and a name diagnostic all land in the inline editor with one
4600
+ * mechanism.
4601
+ */
4602
+ renameTargetFor(options) {
4603
+ const open = this.renameTarget;
4604
+ if (open && open.groupId === options.groupId && open.ruleId === options.ruleId) {
4605
+ open.namePath = options.namePath;
4606
+ return open;
4607
+ }
4608
+ if (this.focusRequest !== options.namePath) return void 0;
4609
+ if (open && !this.renamingInProgress) {
4610
+ this.renamingInProgress = true;
4611
+ try {
4612
+ this.commitRename();
4613
+ } finally {
4614
+ this.renamingInProgress = false;
4615
+ }
4616
+ if (this.renameTarget) return void 0;
4617
+ }
4618
+ const value = this.currentName(options.groupId, options.ruleId);
4619
+ if (value === void 0) return void 0;
4620
+ const target = {
4621
+ kind: options.kind,
4622
+ groupId: options.groupId,
4623
+ ruleId: options.ruleId,
4624
+ namePath: options.namePath,
4625
+ value
4626
+ };
4627
+ this.renameTarget = target;
4628
+ return target;
4629
+ }
4630
+ currentName(groupId, ruleId) {
4631
+ const group = this.groupById(groupId);
4632
+ if (!group) return void 0;
4633
+ if (ruleId === void 0) return safeText(group.name);
4634
+ const rule = this.ruleById(groupId, ruleId);
4635
+ return rule ? safeText(rule.name) : void 0;
4636
+ }
4637
+ /**
4638
+ * Every group and rule name in the project, except the entity being renamed.
4639
+ * Compared through the shared normalized key, so a rename is refused for a name
4640
+ * that differs only by case or internal spacing.
4641
+ */
4642
+ reservedNameKeys(exclude) {
4643
+ const reserved = /* @__PURE__ */ new Set();
4644
+ for (const group of this.draft.groups) {
4645
+ const isTargetGroup = group.id === exclude.groupId;
4646
+ if (!isTargetGroup && typeof group.name === "string") {
4647
+ reserved.add(normalizeNameKey(group.name));
4648
+ }
4649
+ for (const rule of group.rules) {
4650
+ if (isTargetGroup && rule.id === exclude.ruleId) continue;
4651
+ if (typeof rule.name === "string") {
4652
+ reserved.add(normalizeNameKey(rule.name));
4653
+ }
4654
+ }
4655
+ }
4656
+ return reserved;
4657
+ }
4658
+ /**
4659
+ * A human description of whichever entity already holds a normalized name, so
4660
+ * a refused rename can name the conflict instead of only reporting that one
4661
+ * exists.
4662
+ */
4663
+ nameHolderFor(key, exclude) {
4664
+ for (const group of this.draft.groups) {
4665
+ if (typeof group.name === "string" && normalizeNameKey(group.name) === key) {
4666
+ if (group.id === exclude.groupId && exclude.ruleId === void 0)
4667
+ continue;
4668
+ return `group \u201C${displayName(group.name, "Unnamed group")}\u201D`;
4669
+ }
4670
+ for (const rule of group.rules) {
4671
+ if (typeof rule.name !== "string") continue;
4672
+ if (normalizeNameKey(rule.name) !== key) continue;
4673
+ if (group.id === exclude.groupId && rule.id === exclude.ruleId)
4674
+ continue;
4675
+ return `rule \u201C${displayName(rule.name, "Unnamed rule")}\u201D`;
4676
+ }
4677
+ }
4678
+ return void 0;
4679
+ }
4680
+ openRename(kind, groupId, ruleId) {
4681
+ if (this.saving) return;
4682
+ const groupIndex = this.groupIndex(groupId);
4683
+ if (groupIndex === -1) return;
4684
+ const group = this.draft.groups[groupIndex];
4685
+ if (!group) return;
4686
+ const namePath = ruleId === void 0 ? pointer("groups", groupIndex, "name") : pointer(
4687
+ "groups",
4688
+ groupIndex,
4689
+ "rules",
4690
+ this.ruleIndex(group, ruleId),
4691
+ "name"
4692
+ );
4693
+ const value = this.currentName(groupId, ruleId);
4694
+ if (value === void 0) return;
4695
+ this.route = { kind: "group", groupId };
4696
+ this.renameTarget = { kind, groupId, ruleId, namePath, value };
4697
+ this.composing = false;
4698
+ this.focusRequest = namePath;
4699
+ this.render();
4700
+ }
4701
+ cancelRename() {
4702
+ const target = this.renameTarget;
4703
+ if (!target) return;
4704
+ this.renameTarget = void 0;
4705
+ this.statusMessage = "Name change discarded.";
4706
+ this.render();
4707
+ this.focusRenameControl(target.groupId, target.ruleId);
4708
+ }
4709
+ focusRenameControl(groupId, ruleId) {
4710
+ for (const button of this.host.querySelectorAll(
4711
+ '[data-command="rename-entity"]'
4712
+ )) {
4713
+ if (button.dataset.groupId !== groupId) continue;
4714
+ if ((button.dataset.ruleId ?? void 0) !== ruleId) continue;
4715
+ button.focus();
4716
+ return;
4717
+ }
4718
+ }
4719
+ /** Refuse a commit, keep the editor open, and say why. */
4720
+ rejectRename(target, message) {
4721
+ this.statusMessage = message;
4722
+ this.focusRequest = target.namePath;
4723
+ this.render();
4724
+ }
4725
+ /**
4726
+ * Write a name onto the entity the rename targets, resolved by identity.
4727
+ *
4728
+ * `setValueAtPath` refuses to create a property that is not already there,
4729
+ * because its allowlist exists to stop a field input inventing arbitrary keys.
4730
+ * A `name` is different: a hand-authored project can legitimately arrive with
4731
+ * the property *absent*, and the diagnostic that reports it is exactly the one
4732
+ * the inline editor exists to repair. So the name is written here, onto a
4733
+ * record resolved by id, which also means a stale index cannot land the value
4734
+ * on a different entity.
4735
+ */
4736
+ writeEntityName(groupId, ruleId, value) {
4737
+ const group = this.groupById(groupId);
4738
+ if (!group) return false;
4739
+ const rule = ruleId === void 0 ? void 0 : this.ruleById(groupId, ruleId);
4740
+ if (ruleId !== void 0 && !rule) return false;
4741
+ const target = rule ?? group;
4742
+ if (target === void 0) return false;
4743
+ if (target.name === value) return false;
4744
+ Object.defineProperty(target, "name", {
4745
+ configurable: true,
4746
+ enumerable: true,
4747
+ value,
4748
+ writable: true
4749
+ });
4750
+ return true;
4751
+ }
4752
+ commitRename() {
4753
+ const target = this.renameTarget;
4754
+ if (!target || this.saving) return;
4755
+ const trimmed = target.value.trim();
4756
+ if (trimmed.length === 0) {
4757
+ this.rejectRename(target, "A name cannot be empty.");
4758
+ return;
4759
+ }
4760
+ if (trimmed.length > LIMITS.maxLabelLength) {
4761
+ this.rejectRename(
4762
+ target,
4763
+ `A name cannot be longer than ${LIMITS.maxLabelLength} characters.`
4764
+ );
4765
+ return;
4766
+ }
4767
+ const key = normalizeNameKey(trimmed);
4768
+ if (this.reservedNameKeys(target).has(key)) {
4769
+ const holder = this.nameHolderFor(key, target);
4770
+ this.rejectRename(
4771
+ target,
4772
+ holder ? `The ${holder} already uses that name. Pick a different name.` : "That name is already used in this project."
4773
+ );
4774
+ return;
4775
+ }
4776
+ const changed = this.writeEntityName(
4777
+ target.groupId,
4778
+ target.ruleId,
4779
+ trimmed
4780
+ );
4781
+ this.renameTarget = void 0;
4782
+ if (!changed) {
4783
+ this.statusMessage = "Name unchanged.";
4784
+ this.render();
4785
+ return;
4786
+ }
4787
+ this.markChanged();
4788
+ this.statusMessage = "Name updated.";
4789
+ this.render();
4790
+ }
4362
4791
  renderSummary() {
4363
4792
  this.summary.replaceChildren();
4364
4793
  this.summary.hidden = this.errors.length === 0;
@@ -4373,10 +4802,89 @@ var EditorControllerImpl = class {
4373
4802
  button.dataset.errorPath = error.path;
4374
4803
  button.textContent = `${error.message} (${error.path || "project"})`;
4375
4804
  item.append(button);
4805
+ const repair = this.idRepairFor(error.path);
4806
+ if (repair) {
4807
+ const fix = this.createCommandButton(
4808
+ "Assign a new ID",
4809
+ "repair-id",
4810
+ this.saving,
4811
+ { repairPath: error.path },
4812
+ "secondary"
4813
+ );
4814
+ fix.setAttribute("aria-label", repair.label);
4815
+ item.append(fix);
4816
+ }
4376
4817
  list.append(item);
4377
4818
  }
4378
4819
  this.summary.append(heading, list);
4379
4820
  }
4821
+ /** A label for the entity an id diagnostic names, when it names one. */
4822
+ idRepairFor(path) {
4823
+ const segments = decodePointer(path);
4824
+ if (segments?.[0] !== "groups" || segments[segments.length - 1] !== "id") {
4825
+ return void 0;
4826
+ }
4827
+ const entity = this.entityAtIdPath(segments);
4828
+ if (!entity) return void 0;
4829
+ return { label: entity.label };
4830
+ }
4831
+ /**
4832
+ * Resolve the entity an id path names.
4833
+ *
4834
+ * By index, not by id: the whole point of this path is that the id is wrong,
4835
+ * and two entities can share it, so an id lookup would be ambiguous and could
4836
+ * repair the wrong one.
4837
+ */
4838
+ entityAtIdPath(segments) {
4839
+ if (!segments) return void 0;
4840
+ const groupIndex = arrayIndex(segments[1] ?? "");
4841
+ if (groupIndex === void 0) return void 0;
4842
+ const group = this.draft.groups[groupIndex];
4843
+ if (!group) return void 0;
4844
+ if (segments.length === 3) {
4845
+ return {
4846
+ group,
4847
+ label: `Assign a new ID to group ${displayName(group.name, "Unnamed group")}`
4848
+ };
4849
+ }
4850
+ if (segments.length === 5) {
4851
+ const ruleIndex = arrayIndex(segments[3] ?? "");
4852
+ if (ruleIndex === void 0) return void 0;
4853
+ const rule = group.rules[ruleIndex];
4854
+ if (!rule) return void 0;
4855
+ return {
4856
+ group,
4857
+ rule,
4858
+ label: `Assign a new ID to rule ${displayName(rule.name, "Unnamed rule")}`
4859
+ };
4860
+ }
4861
+ return void 0;
4862
+ }
4863
+ repairEntityId(path) {
4864
+ if (this.saving) return;
4865
+ const entity = this.entityAtIdPath(decodePointer(path));
4866
+ if (!entity) return;
4867
+ const { group, rule } = entity;
4868
+ const openIndex = this.route.kind === "group" ? this.groupIndex(this.route.groupId) : -1;
4869
+ const repairedIndex = this.draft.groups.indexOf(group);
4870
+ const name = rule === void 0 ? group.name : rule.name;
4871
+ const next = deriveEntityId(
4872
+ name,
4873
+ rule === void 0 ? "group" : "rule",
4874
+ this.allIds()
4875
+ );
4876
+ if (!setValueAtPath(this.draft, path, next)) {
4877
+ this.statusMessage = "ID unchanged.";
4878
+ this.render();
4879
+ return;
4880
+ }
4881
+ this.markChanged();
4882
+ this.statusMessage = `Assigned the ID ${next}.`;
4883
+ if (openIndex === repairedIndex) {
4884
+ this.route = { kind: "group", groupId: String(group.id) };
4885
+ }
4886
+ this.validate();
4887
+ }
4380
4888
  renderSearchResults() {
4381
4889
  const root = this.searchResults;
4382
4890
  root.replaceChildren();
@@ -218,6 +218,15 @@ var LIMITS = Object.freeze({
218
218
  maxRequestBodyOperations: 32,
219
219
  maxLocalOrigins: 32
220
220
  });
221
+ var WHITESPACE_RUN = /\s+/gu;
222
+ var FALLBACK_PREFIX = Object.freeze({
223
+ group: "Group",
224
+ rule: "Rule"
225
+ });
226
+ function normalizeNameKey(name) {
227
+ if (typeof name !== "string") return "";
228
+ return name.trim().replace(WHITESPACE_RUN, " ").toLowerCase();
229
+ }
221
230
  function normalizeSiteOrigin(value) {
222
231
  if (typeof value !== "string" || value.length === 0 || value.trim() !== value)
223
232
  return null;
@@ -972,12 +981,24 @@ ajv.addFormat("rogatio-url-regex", {
972
981
  });
973
982
  function ajvIssues(errors) {
974
983
  return (errors ?? []).map((error) => ({
975
- instancePath: error.instancePath,
984
+ // Ajv reports a `required` violation against the object that lacks the
985
+ // property, with the property named in `params.missingProperty`. Left alone,
986
+ // that parent path names a location no field or entity owns, so the missing
987
+ // property's own path is reconstructed here — once, for every Ajv consumer.
988
+ instancePath: missingPropertyPath(error),
976
989
  keyword: error.keyword,
977
990
  message: error.message ?? "validation failed",
978
991
  params: error.params
979
992
  }));
980
993
  }
994
+ function missingPropertyPath(error) {
995
+ if (error.keyword !== "required") return error.instancePath;
996
+ const missing = error.params?.missingProperty;
997
+ if (typeof missing !== "string" || missing.length === 0) {
998
+ return error.instancePath;
999
+ }
1000
+ return `${error.instancePath}/${missing}`;
1001
+ }
981
1002
  function snapshotOwnData(value, ancestors = /* @__PURE__ */ new WeakSet()) {
982
1003
  if (value === null || typeof value !== "object") {
983
1004
  return { valid: true, value };
@@ -1067,7 +1088,23 @@ var projectValidator = guardedProjectValidator;
1067
1088
  function semanticIssues(project) {
1068
1089
  const issues = [];
1069
1090
  const ids = /* @__PURE__ */ new Map();
1091
+ const names = /* @__PURE__ */ new Map();
1070
1092
  let ruleCount = 0;
1093
+ const claimName = (name, namePath) => {
1094
+ if (typeof name !== "string" || name.length === 0) return;
1095
+ const key = normalizeNameKey(name);
1096
+ const previous = names.get(key);
1097
+ if (previous === void 0) {
1098
+ names.set(key, namePath);
1099
+ return;
1100
+ }
1101
+ issues.push({
1102
+ instancePath: namePath,
1103
+ keyword: "uniqueName",
1104
+ message: `must be unique; already used at ${previous}`,
1105
+ params: { previousPath: previous }
1106
+ });
1107
+ };
1071
1108
  for (let groupIndex = 0; groupIndex < project.groups.length; groupIndex += 1) {
1072
1109
  const group = project.groups[groupIndex];
1073
1110
  const groupPath = `/groups/${groupIndex}`;
@@ -1082,6 +1119,7 @@ function semanticIssues(project) {
1082
1119
  } else {
1083
1120
  ids.set(group.id, `${groupPath}/id`);
1084
1121
  }
1122
+ claimName(group.name, `${groupPath}/name`);
1085
1123
  for (let ruleIndex = 0; ruleIndex < group.rules.length; ruleIndex += 1) {
1086
1124
  const rule = group.rules[ruleIndex];
1087
1125
  ruleCount += 1;
@@ -1097,6 +1135,7 @@ function semanticIssues(project) {
1097
1135
  } else {
1098
1136
  ids.set(rule.id, `${rulePath}/id`);
1099
1137
  }
1138
+ claimName(rule.name, `${rulePath}/name`);
1100
1139
  if (rule.type !== void 0 && rule.type !== "redirect" && rule.type !== "query" && rule.type !== "header" && rule.type !== "response-body" && rule.type !== "request-body") {
1101
1140
  issues.push({
1102
1141
  instancePath: `${rulePath}/type`,
@@ -1620,6 +1659,7 @@ var ISSUE_CODES = {
1620
1659
  uniqueItems: "schema.out-of-range",
1621
1660
  ownProperties: "schema.invalid-structure",
1622
1661
  uniqueId: "schema.duplicate-id",
1662
+ uniqueName: "schema.duplicate-name",
1623
1663
  maxRulesPerProject: "schema.rule-limit"
1624
1664
  };
1625
1665
  var SAFE_PARAM_KEYS = /* @__PURE__ */ new Set([
@@ -1647,6 +1687,7 @@ var MESSAGES = {
1647
1687
  "schema.out-of-range": "The project contains a value outside its allowed bounds.",
1648
1688
  "schema.invalid-structure": "The project contains invalid structure.",
1649
1689
  "schema.duplicate-id": "Project and rule IDs must be unique.",
1690
+ "schema.duplicate-name": "Group and rule names must be unique within a project.",
1650
1691
  "schema.rule-limit": "The project contains too many rules.",
1651
1692
  "compiler.invariant": "The compiler could not normalize validated project data.",
1652
1693
  "compiler.forbidden-header": "The header name is forbidden for this direction.",
@@ -20972,12 +21013,22 @@ Replace-mode body may use URL captures. Regex-mode replacement strings use captu
20972
21013
  Replace-mode body may use URL captures. Regex-mode replacement strings use captures from the body pattern, not the source pattern.
20973
21014
 
20974
21015
  ## Compiler Diagnostic Codes (stable)
20975
- - schema.invalid-regex - Invalid regex syntax
20976
- - schema.duplicate-id - Duplicate group/rule ID
20977
- - schema.invalid-resource-type - Unknown resource type
20978
- - schema.invalid-method - Unknown HTTP method
20979
- - schema.regex-too-long - Regex exceeds 2048 chars
20980
- - compiler.unsupported-operation - Operation kind not supported
21016
+ - schema.required - Required project data is missing
21017
+ - schema.unknown-property - The project contains an unknown property
21018
+ - schema.invalid-type - A value has an invalid type
21019
+ - schema.invalid-format - A value has an invalid format
21020
+ - schema.invalid-value - A value is invalid
21021
+ - schema.out-of-range - A value is outside its allowed bounds
21022
+ - schema.invalid-structure - A value has invalid structure
21023
+ - schema.duplicate-id - Duplicate group or rule ID
21024
+ - schema.duplicate-name - Duplicate group or rule name; every group and rule name must be unique in the project
21025
+ - schema.rule-limit - The project contains too many rules
21026
+ - compiler.invariant - The compiler could not normalize the project
21027
+ - compiler.forbidden-header - The header is forbidden for this direction
21028
+ - compiler.header-value-required - A header value is required for set and append
21029
+ - compiler.header-value-unexpected - A header value is not allowed for remove
21030
+ - compiler.invalid-header-direction - Invalid header direction
21031
+ - compiler.invalid-header-operation - Invalid header operation
20981
21032
 
20982
21033
  ## Current Project
20983
21034
  ${projectJson}
@@ -22595,12 +22646,42 @@ async function startInterceptProxy(options = {}) {
22595
22646
  }
22596
22647
  };
22597
22648
  }
22649
+ function pacCheck(route) {
22650
+ if (typeof route.url === "string" && isExactHttpUrl(route.url)) {
22651
+ return {
22652
+ key: `url:${route.url}`,
22653
+ line: `url === ${JSON.stringify(route.url)}`
22654
+ };
22655
+ }
22656
+ if (typeof route.hostname === "string" && route.hostname.length > 0 && !route.hostname.includes("://")) {
22657
+ return {
22658
+ key: `host:${route.hostname}`,
22659
+ line: `host === ${JSON.stringify(route.hostname)}`
22660
+ };
22661
+ }
22662
+ return null;
22663
+ }
22664
+ function isExactHttpUrl(value) {
22665
+ try {
22666
+ const url = new URL(value);
22667
+ return (url.protocol === "http:" || url.protocol === "https:") && url.username === "" && url.password === "" && url.href === value;
22668
+ } catch {
22669
+ return false;
22670
+ }
22671
+ }
22598
22672
  function generatePacScript(routes, endpoint, options) {
22599
22673
  if (!Array.isArray(routes)) {
22600
22674
  throw new Error("runtime.pac-route-limit");
22601
22675
  }
22602
- const unique = routes.map((route) => route.hostname).filter((hostname) => typeof hostname === "string" && hostname.length > 0);
22603
- const sorted = [...new Set(unique)].sort();
22676
+ const checksByKey = /* @__PURE__ */ new Map();
22677
+ for (const route of routes) {
22678
+ const check = pacCheck(route);
22679
+ if (check === null) continue;
22680
+ checksByKey.set(check.key, check.line);
22681
+ }
22682
+ const sorted = [...checksByKey.entries()].sort(
22683
+ ([left], [right]) => left.localeCompare(right)
22684
+ );
22604
22685
  if (sorted.length > MAX_PAC_ROUTES) {
22605
22686
  throw new Error("runtime.pac-route-limit");
22606
22687
  }
@@ -22608,7 +22689,7 @@ function generatePacScript(routes, endpoint, options) {
22608
22689
  const proxy = `${proxyType} ${endpoint.host}:${endpoint.port}`;
22609
22690
  const proxyLiteral = JSON.stringify(proxy);
22610
22691
  const checks = sorted.map(
22611
- (hostname) => ` if (host === ${JSON.stringify(hostname)}) {
22692
+ ([, predicate]) => ` if (${predicate}) {
22612
22693
  return ${proxyLiteral};
22613
22694
  }`
22614
22695
  );
@@ -22724,7 +22805,9 @@ function createPlatformInterceptionProvider(adapter) {
22724
22805
  }
22725
22806
  const endpoint = await adapter.startTlsProxy(activation);
22726
22807
  const pac = generatePacScript(
22727
- pacRoutes.map((hostname) => ({ hostname })),
22808
+ pacRoutes.map(
22809
+ (entry) => entry.includes("://") ? { url: entry } : { hostname: entry }
22810
+ ),
22728
22811
  endpoint
22729
22812
  );
22730
22813
  try {
@@ -27710,6 +27793,20 @@ async function testCommandImpl(args, stdinInput, captureOutput, options = {}) {
27710
27793
  if (captureOutput) return output;
27711
27794
  console.log(output.trim());
27712
27795
  } else {
27796
+ const groupNames = /* @__PURE__ */ new Map();
27797
+ if (schemaResult.valid) {
27798
+ for (const group of schemaResult.data.groups) {
27799
+ groupNames.set(group.id, group.name);
27800
+ }
27801
+ }
27802
+ const labels = /* @__PURE__ */ new Map();
27803
+ for (const operation of compileResult.operations) {
27804
+ const groupName = groupNames.get(operation.groupId);
27805
+ labels.set(
27806
+ `${operation.groupId}/${operation.ruleId}`,
27807
+ groupName === void 0 ? operation.name : `${groupName} / ${operation.name}`
27808
+ );
27809
+ }
27713
27810
  let output = "";
27714
27811
  for (const urlResult of dryRunResult.results) {
27715
27812
  output += `
@@ -27718,7 +27815,9 @@ URL: ${urlResult.url}
27718
27815
  output += ` Matched rules: ${urlResult.matchedRuleCount}
27719
27816
  `;
27720
27817
  for (const rule of urlResult.rules) {
27721
- output += ` ${rule.groupId}/${rule.ruleId}: ${rule.matched ? "MATCHED" : "NOT MATCHED"}
27818
+ const key = `${rule.groupId}/${rule.ruleId}`;
27819
+ const label2 = labels.get(key) ?? key;
27820
+ output += ` ${label2}: ${rule.matched ? "MATCHED" : "NOT MATCHED"}
27722
27821
  `;
27723
27822
  output += ` source: ${rule.source.state} - ${rule.source.detail}
27724
27823
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogatio/cli",
3
- "version": "6.3.0",
3
+ "version": "6.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local-first browser request and response rules — editor host, file verification, test runner, and runtime dispatch.",