@rogatio/cli 6.2.0 → 6.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.
@@ -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}
@@ -21706,7 +21757,8 @@ var ENVELOPE_MESSAGE_TYPES = /* @__PURE__ */ new Set([
21706
21757
  "mock.response",
21707
21758
  "ai.complete",
21708
21759
  "ai.stream.chunk",
21709
- "ai.error"
21760
+ "ai.error",
21761
+ "ai.status"
21710
21762
  ]);
21711
21763
  var EnvelopeError = class extends Error {
21712
21764
  constructor(message) {
@@ -23313,9 +23365,36 @@ function createNativeRuntimeController(options) {
23313
23365
  let capability;
23314
23366
  let activePolicy = null;
23315
23367
  const mockTokens = /* @__PURE__ */ new Map();
23368
+ let aiConfigInForce = options.aiProviderConfig ?? null;
23316
23369
  let aiClient;
23317
- if (options.aiProviderConfig) {
23318
- aiClient = createAIClient(options.aiProviderConfig);
23370
+ if (aiConfigInForce) {
23371
+ aiClient = createAIClient(aiConfigInForce);
23372
+ }
23373
+ function isUsableAIProviderConfig(value) {
23374
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
23375
+ return false;
23376
+ }
23377
+ const record = value;
23378
+ return typeof record.providerUrl === "string" && record.providerUrl.length > 0 && typeof record.model === "string" && record.model.length > 0 && typeof record.apiKey === "string" && record.apiKey.length > 0;
23379
+ }
23380
+ function canonicalAIConfig(config) {
23381
+ return config === null ? "null" : JSON.stringify([config.providerUrl, config.model, config.apiKey]);
23382
+ }
23383
+ async function refreshAIProviderConfig() {
23384
+ const reader = options.aiConfigReader;
23385
+ if (!reader) return aiConfigInForce;
23386
+ let next = null;
23387
+ try {
23388
+ const read = await reader();
23389
+ next = isUsableAIProviderConfig(read) ? read : null;
23390
+ } catch {
23391
+ next = null;
23392
+ }
23393
+ if (canonicalAIConfig(next) !== canonicalAIConfig(aiConfigInForce)) {
23394
+ aiConfigInForce = next;
23395
+ aiClient = next ? createAIClient(next) : void 0;
23396
+ }
23397
+ return aiConfigInForce;
23319
23398
  }
23320
23399
  async function bindInterception(current, sessionConfig) {
23321
23400
  if (sessionConfig.pacRoutes.length === 0) {
@@ -23747,7 +23826,23 @@ function createNativeRuntimeController(options) {
23747
23826
  metadata: { state, presetDigest: preset.digest }
23748
23827
  };
23749
23828
  }
23829
+ case "ai.status": {
23830
+ const config = await refreshAIProviderConfig();
23831
+ const metadata = config !== null ? {
23832
+ configured: true,
23833
+ providerUrl: config.providerUrl,
23834
+ model: config.model
23835
+ } : { configured: false };
23836
+ return {
23837
+ protocol: "v1",
23838
+ type: "ai.status",
23839
+ ...requestId !== void 0 ? { requestId } : {},
23840
+ timestamp,
23841
+ metadata
23842
+ };
23843
+ }
23750
23844
  case "ai.complete": {
23845
+ await refreshAIProviderConfig();
23751
23846
  if (!aiClient) {
23752
23847
  return {
23753
23848
  protocol: "v1",
@@ -23765,7 +23860,7 @@ function createNativeRuntimeController(options) {
23765
23860
  try {
23766
23861
  const result = await aiClient.complete({
23767
23862
  messages: [...meta.messages],
23768
- model: meta.model || options.aiProviderConfig?.model || "",
23863
+ model: meta.model || aiConfigInForce?.model || "",
23769
23864
  temperature: meta.temperature,
23770
23865
  responseFormat: meta.responseFormat
23771
23866
  });
@@ -23794,6 +23889,7 @@ function createNativeRuntimeController(options) {
23794
23889
  }
23795
23890
  }
23796
23891
  case "ai.stream.chunk": {
23892
+ await refreshAIProviderConfig();
23797
23893
  if (!aiClient) {
23798
23894
  return {
23799
23895
  protocol: "v1",
@@ -23811,7 +23907,7 @@ function createNativeRuntimeController(options) {
23811
23907
  try {
23812
23908
  for await (const chunk of aiClient.stream({
23813
23909
  messages: [...meta.messages],
23814
- model: meta.model || options.aiProviderConfig?.model || "",
23910
+ model: meta.model || aiConfigInForce?.model || "",
23815
23911
  temperature: meta.temperature
23816
23912
  })) {
23817
23913
  return {
@@ -24331,6 +24427,7 @@ function createNativeHost(options) {
24331
24427
  fileRoot: options.fileRoot,
24332
24428
  ...options.mockPort !== void 0 ? { mockPort: options.mockPort } : {},
24333
24429
  ...options.aiProviderConfig !== void 0 ? { aiProviderConfig: options.aiProviderConfig } : {},
24430
+ ...options.aiConfigReader !== void 0 ? { aiConfigReader: options.aiConfigReader } : {},
24334
24431
  ...options.clock ? { clock: options.clock } : {}
24335
24432
  });
24336
24433
  let faucet = null;
@@ -27262,6 +27359,7 @@ async function runtimeHostCommand(args, options = {}) {
27262
27359
  fileRoot: root,
27263
27360
  ...mockPort !== void 0 ? { mockPort } : {},
27264
27361
  ...aiProviderConfig2 !== null ? { aiProviderConfig: aiProviderConfig2 } : {},
27362
+ aiConfigReader: readProviderConfig,
27265
27363
  onReady: () => console.error(
27266
27364
  "rogatio runtime-host active (deferred, waiting for project)"
27267
27365
  )
@@ -27323,6 +27421,7 @@ async function runtimeHostCommand(args, options = {}) {
27323
27421
  fileRoot: rootDir,
27324
27422
  ...mockPort !== void 0 ? { mockPort } : {},
27325
27423
  ...aiProviderConfig !== null ? { aiProviderConfig } : {},
27424
+ aiConfigReader: readProviderConfig,
27326
27425
  onReady: () => console.error("rogatio runtime-host active")
27327
27426
  });
27328
27427
  return 0;
@@ -27662,6 +27761,20 @@ async function testCommandImpl(args, stdinInput, captureOutput, options = {}) {
27662
27761
  if (captureOutput) return output;
27663
27762
  console.log(output.trim());
27664
27763
  } else {
27764
+ const groupNames = /* @__PURE__ */ new Map();
27765
+ if (schemaResult.valid) {
27766
+ for (const group of schemaResult.data.groups) {
27767
+ groupNames.set(group.id, group.name);
27768
+ }
27769
+ }
27770
+ const labels = /* @__PURE__ */ new Map();
27771
+ for (const operation of compileResult.operations) {
27772
+ const groupName = groupNames.get(operation.groupId);
27773
+ labels.set(
27774
+ `${operation.groupId}/${operation.ruleId}`,
27775
+ groupName === void 0 ? operation.name : `${groupName} / ${operation.name}`
27776
+ );
27777
+ }
27665
27778
  let output = "";
27666
27779
  for (const urlResult of dryRunResult.results) {
27667
27780
  output += `
@@ -27670,7 +27783,9 @@ URL: ${urlResult.url}
27670
27783
  output += ` Matched rules: ${urlResult.matchedRuleCount}
27671
27784
  `;
27672
27785
  for (const rule of urlResult.rules) {
27673
- output += ` ${rule.groupId}/${rule.ruleId}: ${rule.matched ? "MATCHED" : "NOT MATCHED"}
27786
+ const key = `${rule.groupId}/${rule.ruleId}`;
27787
+ const label2 = labels.get(key) ?? key;
27788
+ output += ` ${label2}: ${rule.matched ? "MATCHED" : "NOT MATCHED"}
27674
27789
  `;
27675
27790
  output += ` source: ${rule.source.state} - ${rule.source.detail}
27676
27791
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rogatio/cli",
3
- "version": "6.2.0",
3
+ "version": "6.4.0",
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.",