@herbertgao/pi-impeccable 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +191 -0
  2. package/NOTICE.md +11 -0
  3. package/README.md +33 -2
  4. package/package.json +51 -4
  5. package/skills/impeccable/SKILL.md +89 -0
  6. package/skills/impeccable/reference/adapt.md +318 -0
  7. package/skills/impeccable/reference/adapt.native.md +58 -0
  8. package/skills/impeccable/reference/android.md +46 -0
  9. package/skills/impeccable/reference/animate.md +89 -0
  10. package/skills/impeccable/reference/audit.md +139 -0
  11. package/skills/impeccable/reference/audit.native.md +139 -0
  12. package/skills/impeccable/reference/bolder.md +33 -0
  13. package/skills/impeccable/reference/clarify.md +94 -0
  14. package/skills/impeccable/reference/colorize.md +86 -0
  15. package/skills/impeccable/reference/component-review.md +63 -0
  16. package/skills/impeccable/reference/craft-floor.md +44 -0
  17. package/skills/impeccable/reference/craft.md +5 -0
  18. package/skills/impeccable/reference/critique.md +819 -0
  19. package/skills/impeccable/reference/degraded/asset-producer.md +42 -0
  20. package/skills/impeccable/reference/degraded/documenter.md +24 -0
  21. package/skills/impeccable/reference/degraded/finish-reviewer.md +38 -0
  22. package/skills/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  23. package/skills/impeccable/reference/delight.md +70 -0
  24. package/skills/impeccable/reference/distill.md +111 -0
  25. package/skills/impeccable/reference/doctor.md +54 -0
  26. package/skills/impeccable/reference/document.md +416 -0
  27. package/skills/impeccable/reference/extract.md +69 -0
  28. package/skills/impeccable/reference/generate.md +101 -0
  29. package/skills/impeccable/reference/harden.md +362 -0
  30. package/skills/impeccable/reference/hooks.md +114 -0
  31. package/skills/impeccable/reference/init.md +131 -0
  32. package/skills/impeccable/reference/ios.md +51 -0
  33. package/skills/impeccable/reference/layout.md +84 -0
  34. package/skills/impeccable/reference/live-setup.md +104 -0
  35. package/skills/impeccable/reference/live.md +325 -0
  36. package/skills/impeccable/reference/mode-operate.md +21 -0
  37. package/skills/impeccable/reference/mode-persuade.md +19 -0
  38. package/skills/impeccable/reference/mode-read.md +21 -0
  39. package/skills/impeccable/reference/new-work.md +152 -0
  40. package/skills/impeccable/reference/onboard.md +234 -0
  41. package/skills/impeccable/reference/operate.md +61 -0
  42. package/skills/impeccable/reference/optimize.md +257 -0
  43. package/skills/impeccable/reference/overdrive.md +127 -0
  44. package/skills/impeccable/reference/polish.md +105 -0
  45. package/skills/impeccable/reference/quieter.md +99 -0
  46. package/skills/impeccable/reference/region-map.md +26 -0
  47. package/skills/impeccable/reference/routing.md +24 -0
  48. package/skills/impeccable/reference/shape.md +59 -0
  49. package/skills/impeccable/reference/typeset.md +90 -0
  50. package/skills/impeccable/reference/visualize.md +47 -0
  51. package/skills/impeccable/scripts/VERSION +1 -0
  52. package/skills/impeccable/scripts/command-metadata.json +98 -0
  53. package/skills/impeccable/scripts/data/font-index-failures.json +121 -0
  54. package/skills/impeccable/scripts/data/font-index.json +1 -0
  55. package/skills/impeccable/scripts/engine-checksums.txt +9 -0
  56. package/skills/impeccable/scripts/impeccable +249 -0
  57. package/skills/impeccable/scripts/impeccable.cmd +278 -0
  58. package/skills/impeccable/scripts/live-browser-dom.js +320 -0
  59. package/skills/impeccable/scripts/live-browser-ignores.js +246 -0
  60. package/skills/impeccable/scripts/live-browser-session.js +183 -0
  61. package/skills/impeccable/scripts/live-browser.js +13645 -0
  62. package/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Browser-side DOM helpers for Impeccable live mode.
3
+ *
4
+ * Kept separate from live-browser.js so future browser script parts can share
5
+ * chrome mounting, lookup, focus, and picker helpers without depending on the
6
+ * full overlay UI bundle.
7
+ */
8
+ (function (root) {
9
+ 'use strict';
10
+ if (!root) return;
11
+
12
+ function createLiveBrowserDomHelpers({
13
+ prefix,
14
+ skipTags,
15
+ document: doc = root.document,
16
+ css = root.CSS,
17
+ crypto = root.crypto,
18
+ } = {}) {
19
+ if (!prefix) throw new Error('prefix required');
20
+ if (!doc) throw new Error('document required');
21
+ const tagsToSkip = skipTags || new Set();
22
+
23
+ function own(el) {
24
+ return el && (el.id?.startsWith(prefix) || el.closest?.('[id^="' + prefix + '"]'));
25
+ }
26
+
27
+ function pickable(el) {
28
+ if (!el || el.nodeType !== 1) return false;
29
+ if (tagsToSkip.has(String(el.tagName || '').toLowerCase())) return false;
30
+ if (own(el)) return false;
31
+ const r = el.getBoundingClientRect();
32
+ return r.width >= 20 && r.height >= 20;
33
+ }
34
+
35
+ function desc(el) {
36
+ if (!el) return '';
37
+ let s = el.tagName.toLowerCase();
38
+ if (el.id) s += '#' + el.id;
39
+ else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
40
+ return s;
41
+ }
42
+
43
+ function rectIsUsableAnchor(rect) {
44
+ return !!rect && rect.width > 0.5 && rect.height > 0.5;
45
+ }
46
+
47
+ function makeFrozenAnchor(el) {
48
+ if (!el || !el.getBoundingClientRect) return null;
49
+ const r = el.getBoundingClientRect();
50
+ if (!rectIsUsableAnchor(r)) return null;
51
+ const rect = {
52
+ x: r.x, y: r.y,
53
+ top: r.top, left: r.left,
54
+ right: r.right, bottom: r.bottom,
55
+ width: r.width, height: r.height,
56
+ };
57
+ return {
58
+ __impeccableFrozenAnchor: true,
59
+ tagName: el.tagName || 'DIV',
60
+ id: el.id || '',
61
+ classList: el.classList ? [...el.classList] : [],
62
+ hasAttribute: () => false,
63
+ getBoundingClientRect: () => rect,
64
+ };
65
+ }
66
+
67
+ function hasFrameworkHmrOwnership(el) {
68
+ for (let node = el; node; node = node.parentElement) {
69
+ let keys = [];
70
+ try { keys = Object.getOwnPropertyNames(node); } catch {}
71
+ if (keys.some((key) => (
72
+ key.startsWith('__reactFiber$')
73
+ || key.startsWith('__reactProps$')
74
+ || key.startsWith('__reactContainer$')
75
+ || key === '_reactRootContainer'
76
+ || key === '__vueParentComponent'
77
+ || key === '__vue_app__'
78
+ || key === '__vnode'
79
+ || key === '__svelte_meta'
80
+ ))) {
81
+ return true;
82
+ }
83
+ }
84
+ return false;
85
+ }
86
+
87
+ function id8() {
88
+ // randomUUID is secure-context only (issue #1055); getRandomValues is not.
89
+ try {
90
+ if (typeof crypto?.randomUUID === 'function') return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
91
+ } catch { /* fall through */ }
92
+ try {
93
+ if (typeof crypto?.getRandomValues === 'function') {
94
+ const bytes = crypto.getRandomValues(new Uint8Array(4));
95
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
96
+ }
97
+ } catch { /* fall through */ }
98
+ return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8);
99
+ }
100
+
101
+ function cssId(id) {
102
+ if (css?.escape) return css.escape(id);
103
+ return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
104
+ }
105
+
106
+ function liveUiRoot() {
107
+ const uiRoot = root.__IMPECCABLE_LIVE_UI_ROOT__;
108
+ if (uiRoot && typeof uiRoot.appendChild === 'function') return uiRoot;
109
+ return doc.body;
110
+ }
111
+
112
+ // A modal <dialog> (showModal) paints in the top layer and makes every
113
+ // node outside its subtree inert, so chrome on <body> can be neither seen
114
+ // nor clicked while one is open, and no z-index reaches it (issue #879).
115
+ // While a modal is open the chrome parks inside the topmost one, in one
116
+ // popover: inside the dialog it is not inert, and a popover shown after
117
+ // the dialog paints above it. One popover for all of it, because
118
+ // top-layer entries stack by show order, which would drop the z-index
119
+ // order between the roots. The detect overlay (browser-bundle/
120
+ // 40-overlay.js) mounts its outlines here too: it finds the host by this
121
+ // id and places its outlines again on the event of the same name, fired
122
+ // on every move.
123
+ const topLayerHost = doc.createElement('div');
124
+ topLayerHost.id = prefix + '-top-layer';
125
+ // Same box as the SvelteKit shadow host: 0x0, fixed, chrome overflows it.
126
+ // `all: initial` also clears the popover UA box.
127
+ for (const [name, value] of Object.entries({
128
+ all: 'initial', position: 'fixed', top: '0', left: '0', width: '0', height: '0', overflow: 'visible',
129
+ })) topLayerHost.style.setProperty(name, value, 'important');
130
+ const pageRoots = new WeakSet(); // chrome nodes mounted on the page itself
131
+ const openModals = []; // in the order they opened; last is topmost
132
+
133
+ // Modals that were open before watching began have no readable top-layer
134
+ // order, but the topmost one's backdrop covers the viewport, so a hit
135
+ // test lands in it. Raise that one to the top of the stack.
136
+ function raiseHitModal() {
137
+ const i = openModals.indexOf(doc.elementFromPoint(0, 0)?.closest('dialog:modal'));
138
+ if (i !== -1) openModals.push(...openModals.splice(i, 1));
139
+ }
140
+
141
+ function syncTopLayerHost(records = []) {
142
+ for (const { type, target, oldValue } of records) {
143
+ if (type !== 'attributes' || oldValue !== null || !target.matches('dialog:modal')) continue;
144
+ // Opened, or closed and reopened in one task (the observer sees only
145
+ // the end state): either way it is back on top of the top layer, so
146
+ // a host shown inside it earlier has to be shown again. A write to an
147
+ // `open` that was already set (oldValue not null) moved nothing.
148
+ const i = openModals.indexOf(target);
149
+ if (i !== -1) openModals.splice(i, 1);
150
+ openModals.push(target);
151
+ if (topLayerHost.parentNode === target) topLayerHost.remove();
152
+ }
153
+ // A closed dialog, or one removed from the document while open, stops
154
+ // matching :modal. Whichever is now on top may predate the watch.
155
+ const before = openModals.length;
156
+ for (let i = openModals.length - 1; i >= 0; i--) {
157
+ if (!openModals[i].matches('dialog:modal')) openModals.splice(i, 1);
158
+ }
159
+ if (openModals.length < before && openModals.length > 1) raiseHitModal();
160
+ const modal = openModals[openModals.length - 1] || null;
161
+ if (topLayerHost.parentNode === modal) return;
162
+ if (modal) {
163
+ modal.appendChild(topLayerHost);
164
+ for (const el of [...doc.body.children]) if (pageRoots.has(el)) topLayerHost.appendChild(el);
165
+ topLayerHost.showPopover();
166
+ } else {
167
+ doc.body.append(...topLayerHost.childNodes);
168
+ topLayerHost.remove();
169
+ }
170
+ doc.dispatchEvent(new Event(topLayerHost.id));
171
+ }
172
+
173
+ // Returns the function that stops watching and puts the chrome back.
174
+ function watchModalDialogs() {
175
+ if (typeof topLayerHost.showPopover !== 'function') return () => {};
176
+ topLayerHost.popover = 'manual';
177
+ // Parked in the page's dialog, chrome clicks would bubble into its own
178
+ // handlers, such as a click-outside-the-box close. Live listens in capture.
179
+ topLayerHost.addEventListener('click', (e) => e.stopPropagation());
180
+ openModals.push(...doc.querySelectorAll('dialog:modal'));
181
+ raiseHitModal();
182
+ const observer = new MutationObserver(syncTopLayerHost);
183
+ observer.observe(doc, { subtree: true, childList: true, attributes: true, attributeOldValue: true, attributeFilter: ['open'] });
184
+ syncTopLayerHost();
185
+ return () => {
186
+ observer.disconnect();
187
+ openModals.length = 0;
188
+ syncTopLayerHost();
189
+ };
190
+ }
191
+
192
+ // A copy of a page element without the chrome parked inside it, which a
193
+ // picked modal dialog contains.
194
+ function cloneWithoutChrome(el) {
195
+ const clone = el.cloneNode(true);
196
+ if (el.contains(topLayerHost)) clone.querySelector('#' + cssId(topLayerHost.id)).remove();
197
+ return clone;
198
+ }
199
+
200
+ // Mount a chrome node on the page itself: <body>, or the top-layer host
201
+ // while the chrome is parked in a modal.
202
+ function uiAppendToPage(el) {
203
+ pageRoots.add(el);
204
+ (topLayerHost.parentNode ? topLayerHost : doc.body).appendChild(el);
205
+ return el;
206
+ }
207
+
208
+ // Copy text through a selected textarea and execCommand, for origins where
209
+ // navigator.clipboard is missing or refused (plain HTTP, issue #1055).
210
+ // The textarea mounts like any page-level chrome, so while a modal
211
+ // <dialog> is open it sits in that dialog's top-layer host: on <body> it
212
+ // would be inert, and inert text cannot be selected or copied.
213
+ function copyTextViaSelection(text) {
214
+ let ta = null;
215
+ const previous = activeElementDeep();
216
+ try {
217
+ ta = doc.createElement('textarea');
218
+ ta.id = prefix + '-clipboard';
219
+ ta.value = text;
220
+ ta.setAttribute('readonly', '');
221
+ ta.setAttribute('aria-hidden', 'true');
222
+ ta.tabIndex = -1;
223
+ ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;';
224
+ uiAppendToPage(ta);
225
+ ta.select();
226
+ return doc.execCommand('copy');
227
+ } catch {
228
+ return false;
229
+ } finally {
230
+ ta?.remove();
231
+ if (previous && previous !== activeElementDeep() && typeof previous.focus === 'function') {
232
+ try { previous.focus({ preventScroll: true }); } catch { /* best effort */ }
233
+ }
234
+ }
235
+ }
236
+
237
+ function uiAppend(el) {
238
+ const uiRoot = liveUiRoot();
239
+ if (uiRoot === doc.body) return uiAppendToPage(el);
240
+ // An adapter shadow root: its host is the node that sits on the page.
241
+ if (uiRoot.host) pageRoots.add(uiRoot.host);
242
+ uiRoot.appendChild(el);
243
+ return el;
244
+ }
245
+
246
+ function uiAppendStyle(styleEl) {
247
+ const uiRoot = liveUiRoot();
248
+ if (uiRoot && uiRoot !== doc.body) uiRoot.appendChild(styleEl);
249
+ else doc.head.appendChild(styleEl);
250
+ return styleEl;
251
+ }
252
+
253
+ function uiGetById(id) {
254
+ const uiRoot = liveUiRoot();
255
+ if (uiRoot?.getElementById) {
256
+ const found = uiRoot.getElementById(id);
257
+ if (found) return found;
258
+ }
259
+ if (uiRoot?.querySelector) {
260
+ const found = uiRoot.querySelector('#' + cssId(id));
261
+ if (found) return found;
262
+ }
263
+ return doc.getElementById(id);
264
+ }
265
+
266
+ function activeElementDeep() {
267
+ let active = doc.activeElement;
268
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
269
+ return active;
270
+ }
271
+
272
+ function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
273
+ if (!rootEl) return;
274
+ if (setPointerEvents) {
275
+ rootEl.style.setProperty('pointer-events', 'auto', 'important');
276
+ }
277
+ const stop = (e) => e.stopPropagation();
278
+ rootEl.addEventListener('pointerdown', stop);
279
+ rootEl.addEventListener('mousedown', stop);
280
+ rootEl.addEventListener('focusin', stop);
281
+ }
282
+
283
+ // The half of the defang that cannot sit on the chrome: a focus trap (Radix /
284
+ // Reka FocusScope) also hears the page's own focusout as focus leaves its
285
+ // modal for our chrome, and pulls it back. Register on the document in
286
+ // capture, ahead of the trap. The page's own focusout handlers miss that
287
+ // one transition too; blur still fires.
288
+ function stopFocusOutIntoChrome(e) {
289
+ if (own(e.relatedTarget)) e.stopPropagation();
290
+ }
291
+
292
+ return {
293
+ own,
294
+ pickable,
295
+ desc,
296
+ rectIsUsableAnchor,
297
+ makeFrozenAnchor,
298
+ hasFrameworkHmrOwnership,
299
+ id8,
300
+ cssId,
301
+ liveUiRoot,
302
+ uiAppend,
303
+ uiAppendToPage,
304
+ copyTextViaSelection,
305
+ topLayerHost,
306
+ watchModalDialogs,
307
+ cloneWithoutChrome,
308
+ uiAppendStyle,
309
+ uiGetById,
310
+ activeElementDeep,
311
+ defangOutsideHandlers,
312
+ stopFocusOutIntoChrome,
313
+ };
314
+ }
315
+
316
+ root.__IMPECCABLE_LIVE_DOM__ = {
317
+ version: 1,
318
+ createLiveBrowserDomHelpers,
319
+ };
320
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Browser-side resolution of project detector waivers for Impeccable live mode.
3
+ *
4
+ * The live server serializes `.impeccable/config.json` + `config.local.json`
5
+ * detector ignores (plus the served-root prefixes from the inject config's
6
+ * `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
7
+ * resolves that config against the current page's URL path when a detect scan
8
+ * starts, so the overlay suppresses the same findings the CLI and the edit
9
+ * hook do (issue #639).
10
+ *
11
+ * Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
12
+ * 1. `ignoreRules` suppress a rule project-wide.
13
+ * 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
14
+ * files their globs name. The CLI never applies an unscoped wildcard
15
+ * (isIgnoredFindingValue returns false for it), so neither does this.
16
+ * 3. Remaining `ignoreValues` entries match on the finding's own value;
17
+ * those are forwarded as `disabledValues` for the detector bundle to
18
+ * apply where the findings are assembled.
19
+ * 4. `ignoreFiles` globs that name the page waive it wholesale: the
20
+ * resolver reports `skipScan: true` and the detector answers the scan
21
+ * with zero findings, mirroring shouldIgnoreDetectionFile in the CLI
22
+ * and the edit hook's own ignoreFiles gate.
23
+ *
24
+ * `pageFiles`, when the server could resolve it, lists the real project
25
+ * files the inject config serves. A URL that suffix-matches exactly one of
26
+ * them takes that file as its only project identity; an ambiguous or absent
27
+ * match falls back to the served-root common ancestor below.
28
+ *
29
+ * Known gap, unchanged from PR #645: framework apps inject into source files
30
+ * (src/routes/about/+page.svelte) while scans see route URLs (/about), so
31
+ * entries scoped to source or asset paths never match a page candidate and
32
+ * are dropped. That shows the finding, which is the conservative direction.
33
+ *
34
+ * Kept separate from live-browser.js so the glob and page-scope logic can be
35
+ * unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
36
+ * overlay UI bundle.
37
+ */
38
+ (function (root) {
39
+ 'use strict';
40
+ if (!root) return;
41
+
42
+ // Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
43
+ // cli/lib/impeccable-config.mjs.
44
+ function normalizeIgnoreRule(rule) {
45
+ return String(rule || '').trim().toLowerCase();
46
+ }
47
+
48
+ function normalizeIgnoreValue(value) {
49
+ return String(value || '')
50
+ .trim()
51
+ .replace(/^["']|["']$/g, '')
52
+ .replace(/\+/g, ' ')
53
+ .replace(/\s+/g, ' ')
54
+ .toLowerCase();
55
+ }
56
+
57
+ // Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
58
+ // Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
59
+ function globToRegex(glob) {
60
+ let re = '^';
61
+ let i = 0;
62
+ while (i < glob.length) {
63
+ const c = glob[i];
64
+ if (c === '*') {
65
+ if (glob[i + 1] === '*') {
66
+ i += 2;
67
+ if (glob[i] === '/') {
68
+ re += '(?:.*/)?';
69
+ i += 1;
70
+ } else {
71
+ re += '.*';
72
+ }
73
+ } else {
74
+ re += '[^/]*';
75
+ i += 1;
76
+ }
77
+ } else if (c === '?') {
78
+ re += '[^/]';
79
+ i += 1;
80
+ } else if (c === '{') {
81
+ const end = glob.indexOf('}', i);
82
+ if (end === -1) { re += '\\{'; i += 1; continue; }
83
+ const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
84
+ re += `(?:${parts.join('|')})`;
85
+ i = end + 1;
86
+ } else if (/[.+^$()|[\]\\]/.test(c)) {
87
+ re += `\\${c}`;
88
+ i += 1;
89
+ } else {
90
+ re += c;
91
+ i += 1;
92
+ }
93
+ }
94
+ re += '$';
95
+ return new RegExp(re);
96
+ }
97
+
98
+ // The project-relative paths this page could be known as. Ignore globs are
99
+ // project-relative (prototype/foo.html) and the URL is site-relative
100
+ // (/foo.html), because a static server's root usually sits inside the
101
+ // project; `roots` carries that prefix. The server reads it from the inject
102
+ // config's own `files` globs, which already state where the served pages
103
+ // are. Do not derive it from the ignore globs: a single entry scoped to
104
+ // prototype/library/** would then lend prototype/library/ as a candidate
105
+ // prefix to every page, and that rule would suppress site-wide.
106
+ //
107
+ // Each prefixed path also contributes its slash suffixes, mirroring
108
+ // findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
109
+ // matches globs against every path suffix of the finding's file).
110
+ //
111
+ // One live session is served by one server, so a single document root must
112
+ // sit at or above every configured page. The only prefix that can safely
113
+ // be asserted is therefore the deepest common ancestor of the glob roots.
114
+ // Treating each glob's own prefix as an identity goes wrong in both
115
+ // directions: disjoint roots (src/ and public/) invent simultaneous
116
+ // identities for one URL, so a waiver scoped to src/foo.html hides a
117
+ // finding on a page served from public/foo.html; nested roots (prototype/
118
+ // and prototype/library/, from globs at two depths in one tree) are not
119
+ // alternatives at all, and demanding a waiver match under both stops
120
+ // prototype/index.html from applying anywhere. When the globs share no
121
+ // common root, no prefix is asserted and only the URL path itself matches.
122
+ function pageCandidates(pathname, roots, pageFiles) {
123
+ let pagePath = String(pathname || '');
124
+ try {
125
+ pagePath = decodeURIComponent(pagePath);
126
+ } catch {
127
+ // Malformed percent-escape: match on the raw path rather than throwing.
128
+ }
129
+ pagePath = pagePath.replace(/^\/+/, '');
130
+ // A directory URL serves that directory's index, and the ignore globs
131
+ // name files. Without this, /news/ never matches prototype/news/index.html.
132
+ if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
133
+
134
+ const candidates = new Set();
135
+ const addSuffixes = (fullPath) => {
136
+ const parts = fullPath.split('/').filter(Boolean);
137
+ for (let i = 0; i < parts.length; i++) {
138
+ candidates.add(parts.slice(i).join('/'));
139
+ }
140
+ };
141
+ addSuffixes(pagePath);
142
+
143
+ // The served page list names the real files the inject config serves.
144
+ // A URL that suffix-matches exactly one of them has an unambiguous
145
+ // project identity; assert that identity and stop guessing from roots
146
+ // (PR #645 review: with src/ and public/ both served, /foo.html must not
147
+ // borrow src/foo.html's waivers while actually serving public/foo.html).
148
+ // Zero matches or several fall through to the common-ancestor fallback:
149
+ // ambiguity resolves toward showing the finding.
150
+ const knownPages = [];
151
+ for (const entry of Array.isArray(pageFiles) ? pageFiles : []) {
152
+ if (typeof entry !== 'string' || !entry) continue;
153
+ if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry);
154
+ }
155
+ if (knownPages.length === 1) {
156
+ addSuffixes(knownPages[0]);
157
+ return [...candidates];
158
+ }
159
+
160
+ const prefixes = [];
161
+ for (const entry of Array.isArray(roots) ? roots : []) {
162
+ if (typeof entry !== 'string') continue;
163
+ prefixes.push(entry.split('/').filter(Boolean));
164
+ }
165
+ let common = prefixes.length > 0 ? prefixes[0] : [];
166
+ for (const segments of prefixes.slice(1)) {
167
+ let i = 0;
168
+ while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1;
169
+ common = common.slice(0, i);
170
+ }
171
+
172
+ if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath);
173
+ return [...candidates];
174
+ }
175
+
176
+ function matchesScope(globs, candidates) {
177
+ return globs.some((glob) => {
178
+ let re;
179
+ try {
180
+ re = globToRegex(String(glob));
181
+ } catch {
182
+ // Malformed glob: skip it, as matchesAnyGlob does in the CLI.
183
+ return false;
184
+ }
185
+ return candidates.some((candidate) => re.test(candidate));
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Resolve the serialized project ignores for one page.
191
+ *
192
+ * @param {object} options
193
+ * @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__,
194
+ * in whatever state it arrived: absent, null, or hand-edited into the
195
+ * wrong shape. Every read tolerates that and degrades to no filtering.
196
+ * @param {string} options.pathname location.pathname of the scanned page.
197
+ * @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}>, skipScan: boolean }}
198
+ */
199
+ function resolveDetectIgnores({ ignores, pathname } = {}) {
200
+ const config = ignores && typeof ignores === 'object' ? ignores : {};
201
+ const asArray = (value) => (Array.isArray(value) ? value : []);
202
+ const candidates = pageCandidates(pathname, config.roots, config.pageFiles);
203
+
204
+ // detector.ignoreFiles waives whole files. When any glob names this
205
+ // page, the scan itself is skipped; rule and value lists are returned
206
+ // empty because nothing will run.
207
+ const ignoreFileGlobs = asArray(config.ignoreFiles)
208
+ .filter((glob) => typeof glob === 'string' && glob.trim());
209
+ if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) {
210
+ return { disabledRules: [], disabledValues: [], skipScan: true };
211
+ }
212
+
213
+ const disabledRules = new Set(
214
+ asArray(config.ignoreRules)
215
+ .filter((rule) => typeof rule === 'string')
216
+ .map(normalizeIgnoreRule)
217
+ .filter(Boolean),
218
+ );
219
+ const disabledValues = [];
220
+
221
+ for (const entry of asArray(config.ignoreValues)) {
222
+ if (!entry || typeof entry !== 'object') continue;
223
+ const rule = normalizeIgnoreRule(entry.rule);
224
+ const value = normalizeIgnoreValue(entry.value);
225
+ if (!rule || !value) continue;
226
+ const files = [
227
+ ...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
228
+ ...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
229
+ ];
230
+ if (value === '*') {
231
+ // Wildcards suppress their rule only inside the files they name.
232
+ if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
233
+ continue;
234
+ }
235
+ if (files.length > 0 && !matchesScope(files, candidates)) continue;
236
+ disabledValues.push({ rule, value });
237
+ }
238
+
239
+ return { disabledRules: [...disabledRules], disabledValues, skipScan: false };
240
+ }
241
+
242
+ root.__IMPECCABLE_LIVE_IGNORES__ = {
243
+ version: 1,
244
+ resolveDetectIgnores,
245
+ };
246
+ })(typeof window !== 'undefined' ? window : globalThis);