@hegemonart/get-design-done 1.46.0 → 1.47.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.
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/harness-mode.cjs — Phase 47 (Live Mode) capability gate.
4
+ *
5
+ * `/gdd:live` drives the Claude Preview MCP at runtime (preview_inspect /
6
+ * preview_click / preview_eval / preview_screenshot). That whole loop is only
7
+ * available on a harness whose capability matrix reports `mcp_support: true`.
8
+ * Harnesses without MCP support cannot inject the runtime or read picks back, so
9
+ * live mode degrades to a screenshot-only experience on them.
10
+ *
11
+ * This module is the single source of truth for that gate. It reads the canonical
12
+ * harness record (scripts/lib/manifest/harnesses.cjs, a typed re-export of
13
+ * harnesses.json) and projects each harness onto one of two live-mode strings:
14
+ *
15
+ * 'puppeteer' — full live mode: the harness has mcp_support, the skill probes
16
+ * Preview, injects scripts/lib/live/runtime.cjs, and hot-swaps
17
+ * variants in place. (Named for the Preview MCP's Playwright /
18
+ * puppeteer-class browser driver, not a bundled dependency: there
19
+ * is NO bundled puppeteer; the skill drives the MCP.)
20
+ * 'degraded' — screenshot-only: no mcp_support, so no live injection. The skill
21
+ * falls back to generating variants and capturing static
22
+ * screenshots, and says so up front.
23
+ *
24
+ * Design constraints (mirror the other scripts/lib/live/* modules):
25
+ * - Pure, dependency-free apart from the manifest re-export. No fs, no network,
26
+ * no Date / Math.random. Cross-platform (no path work, no OS calls).
27
+ * - The harness list is injectable so tests can pass a fixture; it defaults to
28
+ * the real manifest.
29
+ *
30
+ * Exports:
31
+ * - liveModeFor(harnessId, harnesses?) -> 'puppeteer' | 'degraded'
32
+ * - degradedHarnesses(harnesses?) -> string[] of ids in degraded mode
33
+ * - isMcpSupported(harnessId, harnesses?) -> boolean
34
+ * - MODE_FULL / MODE_DEGRADED -> the two mode string constants
35
+ */
36
+
37
+ const MODE_FULL = 'puppeteer';
38
+ const MODE_DEGRADED = 'degraded';
39
+
40
+ /** Lazily resolve the default harness list (the typed re-export of harnesses.json). */
41
+ function defaultHarnesses() {
42
+ return require('../manifest/harnesses.cjs');
43
+ }
44
+
45
+ /** Find a harness record by id in a list. Returns undefined when absent. */
46
+ function findHarness(harnessId, harnesses) {
47
+ const list = Array.isArray(harnesses) ? harnesses : defaultHarnesses();
48
+ return list.find((h) => h && h.id === harnessId);
49
+ }
50
+
51
+ /**
52
+ * True when the named harness reports `capability_matrix.mcp_support === true`.
53
+ * Unknown harness ids and harnesses missing the flag are treated as false (no
54
+ * MCP -> no live injection).
55
+ */
56
+ function isMcpSupported(harnessId, harnesses) {
57
+ const h = findHarness(harnessId, harnesses);
58
+ return Boolean(h && h.capability_matrix && h.capability_matrix.mcp_support === true);
59
+ }
60
+
61
+ /**
62
+ * Live mode for a harness: 'puppeteer' when mcp_support is true, else 'degraded'.
63
+ * An unknown harness id degrades (fail safe to screenshot-only).
64
+ *
65
+ * @param {string} harnessId
66
+ * @param {Array=} harnesses Optional injected list; defaults to the manifest.
67
+ * @returns {'puppeteer'|'degraded'}
68
+ */
69
+ function liveModeFor(harnessId, harnesses) {
70
+ return isMcpSupported(harnessId, harnesses) ? MODE_FULL : MODE_DEGRADED;
71
+ }
72
+
73
+ /**
74
+ * The ids of every harness currently in degraded (screenshot-only) live mode,
75
+ * in manifest order.
76
+ *
77
+ * @param {Array=} harnesses Optional injected list; defaults to the manifest.
78
+ * @returns {string[]}
79
+ */
80
+ function degradedHarnesses(harnesses) {
81
+ const list = Array.isArray(harnesses) ? harnesses : defaultHarnesses();
82
+ return list
83
+ .filter((h) => h && h.id && !isMcpSupported(h.id, list))
84
+ .map((h) => h.id);
85
+ }
86
+
87
+ module.exports = {
88
+ liveModeFor,
89
+ degradedHarnesses,
90
+ isMcpSupported,
91
+ MODE_FULL,
92
+ MODE_DEGRADED,
93
+ };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/postcheck.cjs — Phase 47 (Live Mode) variant post-check.
4
+ *
5
+ * After /gdd:live generates a design variant, we run that variant's source through the
6
+ * in-repo gdd-detect engine (Phase 41) and surface its anti-pattern findings on the
7
+ * variant card. Per the Live Mode spec, an `error`-severity finding is FLAGGED but NOT
8
+ * auto-rejected — the human still chooses; the detector is advisory at dev-time. So
9
+ * `autoReject` is ALWAYS false; we only surface counts + a compact card summary.
10
+ *
11
+ * Pure, dependency-free CommonJS. We reuse the detect engine's `scanContent` +`RULES`
12
+ * (NOT a CLI shell-out, NOT a temp-file write) so post-checking a generated variant is
13
+ * fully in-memory and hermetically testable. The engine is the single source of truth
14
+ * for the finding shape ({ruleId, category, name, severity, file, line, column, match,
15
+ * references}); we never reimplement rule matching.
16
+ *
17
+ * Ships in the npm package (scripts/lib/** is in package.json `files`), so it requires
18
+ * only the in-repo detect engine — no runtime dependency, no network, no optional dep.
19
+ */
20
+
21
+ const path = require('node:path');
22
+ const { scanContent, run, RULES } = require('../detect/engine.cjs');
23
+
24
+ /**
25
+ * Normalize the caller's `files`/`content` input into a list of
26
+ * `{ path, content, ext }` units to scan in-memory.
27
+ *
28
+ * Accepts either:
29
+ * - `content`: a raw string (single anonymous unit), or
30
+ * - `files`: an array of `{ path, content }` (or `{ name, content }`) units, or
31
+ * an object map of `{ "<path>": "<content>" }`.
32
+ *
33
+ * @param {{files?: any, content?: string}} input
34
+ * @returns {{path: string, content: string, ext: string}[]}
35
+ */
36
+ function normalizeUnits(input) {
37
+ const units = [];
38
+ if (input && typeof input.content === 'string') {
39
+ const p = input.path || input.file || 'variant';
40
+ units.push({ path: p, content: input.content, ext: path.extname(p).toLowerCase() });
41
+ }
42
+ const files = input && input.files;
43
+ if (Array.isArray(files)) {
44
+ for (const f of files) {
45
+ if (!f) continue;
46
+ if (typeof f === 'string') continue; // a bare path with no content is not scannable in-memory
47
+ const p = f.path || f.name || f.file || 'variant';
48
+ const content = typeof f.content === 'string' ? f.content : '';
49
+ units.push({ path: p, content, ext: path.extname(p).toLowerCase() });
50
+ }
51
+ } else if (files && typeof files === 'object') {
52
+ for (const p of Object.keys(files)) {
53
+ const content = typeof files[p] === 'string' ? files[p] : '';
54
+ units.push({ path: p, content, ext: path.extname(p).toLowerCase() });
55
+ }
56
+ }
57
+ return units;
58
+ }
59
+
60
+ /**
61
+ * Post-check a generated variant against the gdd-detect anti-pattern rule set.
62
+ *
63
+ * Prefers an in-memory scan of the supplied variant source via the detect engine's
64
+ * `scanContent`. When NO inline content is supplied but a `projectRoot` is given,
65
+ * falls back to the documented programmatic engine form `run(projectRoot)` so the
66
+ * caller can still post-check files already written to disk.
67
+ *
68
+ * @param {object} args
69
+ * @param {string} [args.projectRoot] Repo root — used for the on-disk fallback and as the
70
+ * `cwd` so finding `file` paths are repo-relative.
71
+ * @param {Array|object} [args.files] Variant files: `[{path, content}]` or `{ "<path>": "<content>" }`.
72
+ * @param {string} [args.content] A single raw variant source string.
73
+ * @returns {{findings: object[], errorCount: number, warnCount: number, autoReject: boolean}}
74
+ */
75
+ function postCheckVariant(args = {}) {
76
+ const { projectRoot } = args;
77
+ const units = normalizeUnits(args);
78
+
79
+ let findings = [];
80
+ if (units.length > 0) {
81
+ const cwd = projectRoot || process.cwd();
82
+ for (const u of units) {
83
+ // Make the reported `file` repo-relative when projectRoot is known, mirroring the
84
+ // engine's own relativization in run().
85
+ let rel = u.path;
86
+ if (projectRoot && path.isAbsolute(u.path)) {
87
+ rel = path.relative(cwd, u.path).split(path.sep).join('/') || u.path;
88
+ }
89
+ const hits = scanContent(u.content, { path: rel, ext: u.ext }, RULES);
90
+ findings.push(...hits);
91
+ }
92
+ // Match the engine's deterministic ordering (file, line, column, ruleId).
93
+ findings.sort(
94
+ (a, b) =>
95
+ a.file.localeCompare(b.file) ||
96
+ a.line - b.line ||
97
+ a.column - b.column ||
98
+ a.ruleId.localeCompare(b.ruleId),
99
+ );
100
+ } else if (projectRoot) {
101
+ // No inline content — fall back to the documented programmatic engine form against
102
+ // files already on disk under projectRoot.
103
+ const result = run(projectRoot, { cwd: projectRoot });
104
+ findings = result.findings;
105
+ }
106
+
107
+ let errorCount = 0;
108
+ let warnCount = 0;
109
+ for (const f of findings) {
110
+ if (f.severity === 'error') errorCount += 1;
111
+ else warnCount += 1;
112
+ }
113
+
114
+ // Spec D: error-severity is flagged, NOT auto-rejected. autoReject is always false.
115
+ return { findings, errorCount, warnCount, autoReject: false };
116
+ }
117
+
118
+ /**
119
+ * Compact, single-line summary of a findings array for a variant card.
120
+ * Always returns a string (never null/undefined) so the skill can render it verbatim.
121
+ *
122
+ * [] → "clean — no anti-patterns"
123
+ * [1 error, 2 warn] → "2 issue(s): 1 error, 2 warn — BAN-06, BAN-01, BAN-08"
124
+ *
125
+ * Rule ids are de-duplicated and listed in first-seen order, capped so the card stays compact.
126
+ *
127
+ * @param {object[]} findings
128
+ * @returns {string}
129
+ */
130
+ function summarizeForCard(findings) {
131
+ const list = Array.isArray(findings) ? findings : [];
132
+ if (list.length === 0) return 'clean — no anti-patterns';
133
+
134
+ let errors = 0;
135
+ let warns = 0;
136
+ const ids = [];
137
+ const seen = new Set();
138
+ for (const f of list) {
139
+ if (f && f.severity === 'error') errors += 1;
140
+ else warns += 1;
141
+ const id = f && f.ruleId;
142
+ if (id && !seen.has(id)) {
143
+ seen.add(id);
144
+ ids.push(id);
145
+ }
146
+ }
147
+
148
+ const MAX_IDS = 5;
149
+ const shownIds = ids.slice(0, MAX_IDS);
150
+ const idTail = ids.length > MAX_IDS ? `, +${ids.length - MAX_IDS} more` : '';
151
+ const parts = [];
152
+ if (errors) parts.push(`${errors} error`);
153
+ if (warns) parts.push(`${warns} warn`);
154
+
155
+ return `${list.length} issue(s): ${parts.join(', ')} — ${shownIds.join(', ')}${idTail}`;
156
+ }
157
+
158
+ module.exports = { postCheckVariant, summarizeForCard, normalizeUnits };
@@ -0,0 +1,233 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/runtime.cjs — Phase 47 (Live Mode) browser-side runtime.
4
+ *
5
+ * The `/gdd:live` skill injects a small browser runtime into the running dev
6
+ * server via the Claude Preview MCP `preview_eval` tool. That runtime does two
7
+ * jobs:
8
+ *
9
+ * 1. PICK — install a one-shot click handler that captures the element the
10
+ * user clicks and reports `{selector, computedStyle subset, boundingRect,
11
+ * tagName, classList}` back to the agent (the live_pick payload).
12
+ * 2. SWAP — apply one of the N generated design variants in-place by setting a
13
+ * `data-gdd-variant="N"` attribute on the picked element and applying that
14
+ * variant's inline style / markup, and read back which variant is live.
15
+ *
16
+ * Why a string and not a real module: `preview_eval` ships a JS source string to
17
+ * the page and evaluates it there. There is no bundler in the loop, so the
18
+ * runtime is authored here as a plain template string (`RUNTIME_JS`) and injected
19
+ * verbatim. The IIFE is idempotent: re-injecting it rebinds the same singleton on
20
+ * `window.__gddLive` rather than stacking duplicate listeners.
21
+ *
22
+ * Design constraints (mirror the other scripts/lib/live/* modules):
23
+ * - Pure, dependency-free CommonJS. No `fs`, no network, no Date.now() at the
24
+ * module top level. `buildSelector` is a pure function of its input.
25
+ * - Cross-platform: this file is plain JS text; nothing here touches the OS.
26
+ * - Ships in the npm package (scripts/lib/ is in package.json `files`), so it
27
+ * stays runtime-safe (no dev-only requires).
28
+ *
29
+ * Exports:
30
+ * - RUNTIME_JS the browser IIFE source string injected via preview_eval.
31
+ * - pickReportShape a plain object documenting the live_pick payload fields.
32
+ * - buildSelector pure helper a test can assert against (id > data-attr >
33
+ * nth-of-type class path), shared with the in-page logic.
34
+ * - DATA_ATTR the variant marker attribute name ("data-gdd-variant").
35
+ * - GLOBAL_KEY the window singleton key ("__gddLive").
36
+ */
37
+
38
+ /** The attribute the runtime stamps on the picked element to mark the live variant. */
39
+ const DATA_ATTR = 'data-gdd-variant';
40
+
41
+ /** The window-singleton key the IIFE installs itself under (idempotent re-inject). */
42
+ const GLOBAL_KEY = '__gddLive';
43
+
44
+ /**
45
+ * Shape of the payload the runtime reports for a pick. Documented here so the
46
+ * skill and the tests agree on the contract without parsing RUNTIME_JS. Values
47
+ * are field descriptions, not live data.
48
+ */
49
+ const pickReportShape = {
50
+ selector: 'string — a stable CSS selector for the picked element (see buildSelector)',
51
+ tagName: 'string — lowercased tag name, e.g. "button"',
52
+ classList: 'string[] — the element classList as an array',
53
+ boundingRect: 'object — {x, y, width, height, top, right, bottom, left} from getBoundingClientRect',
54
+ computedStyle:
55
+ 'object — a curated subset of getComputedStyle: color, backgroundColor, fontSize, ' +
56
+ 'fontWeight, fontFamily, lineHeight, padding, margin, borderRadius, borderColor, ' +
57
+ 'borderWidth, display, boxShadow',
58
+ variant: 'number|null — the current data-gdd-variant value on the element, or null',
59
+ };
60
+
61
+ /**
62
+ * Pure selector strategy, shared with the in-page runtime by being inlined into
63
+ * RUNTIME_JS below. Strategy, most-specific first:
64
+ * 1. `#id` when the element has an id.
65
+ * 2. `[data-testid="..."]` when a stable data-testid / data-test is present.
66
+ * 3. `tag.class1.class2:nth-of-type(k)` — tag + (up to 2) classes + structural
67
+ * nth-of-type index among same-tag siblings, for everything else.
68
+ *
69
+ * @param {{id?:string, dataTestId?:string, tagName?:string, classList?:string[], nthOfType?:number}} elInfo
70
+ * @returns {string}
71
+ */
72
+ function buildSelector(elInfo) {
73
+ const info = elInfo || {};
74
+ if (info.id) return `#${info.id}`;
75
+ if (info.dataTestId) return `[data-testid="${info.dataTestId}"]`;
76
+ const tag = (info.tagName || 'div').toLowerCase();
77
+ const classes = Array.isArray(info.classList) ? info.classList.filter(Boolean).slice(0, 2) : [];
78
+ const classPart = classes.length ? '.' + classes.join('.') : '';
79
+ const nth = Number.isInteger(info.nthOfType) && info.nthOfType > 0 ? `:nth-of-type(${info.nthOfType})` : '';
80
+ return `${tag}${classPart}${nth}`;
81
+ }
82
+
83
+ /**
84
+ * The browser-side runtime, authored as a plain string. Injected verbatim by the
85
+ * skill via `preview_eval`. The in-page `buildSelector` mirrors the exported one
86
+ * above (kept in sync by the phase-47-runtime test asserting both prefer id over
87
+ * class). The IIFE is idempotent: it reuses window.__gddLive if already present.
88
+ */
89
+ const RUNTIME_JS = `
90
+ (function () {
91
+ var GLOBAL_KEY = ${JSON.stringify(GLOBAL_KEY)};
92
+ var DATA_ATTR = ${JSON.stringify(DATA_ATTR)};
93
+
94
+ // Idempotent: reuse the singleton on re-inject so listeners never stack.
95
+ if (window[GLOBAL_KEY] && window[GLOBAL_KEY].__installed) {
96
+ return window[GLOBAL_KEY];
97
+ }
98
+
99
+ // --- pure selector strategy (mirror of runtime.cjs buildSelector) ---
100
+ function buildSelector(el) {
101
+ if (!el || el.nodeType !== 1) return '';
102
+ if (el.id) return '#' + el.id;
103
+ var testId = el.getAttribute && (el.getAttribute('data-testid') || el.getAttribute('data-test'));
104
+ if (testId) return '[data-testid="' + testId + '"]';
105
+ var tag = (el.tagName || 'DIV').toLowerCase();
106
+ var classes = [];
107
+ if (el.classList && el.classList.length) {
108
+ for (var i = 0; i < el.classList.length && classes.length < 2; i++) classes.push(el.classList[i]);
109
+ }
110
+ var classPart = classes.length ? '.' + classes.join('.') : '';
111
+ var nth = '';
112
+ if (el.parentNode) {
113
+ var sameTag = [];
114
+ var sibs = el.parentNode.children || [];
115
+ for (var j = 0; j < sibs.length; j++) if (sibs[j].tagName === el.tagName) sameTag.push(sibs[j]);
116
+ if (sameTag.length > 1) {
117
+ var idx = sameTag.indexOf(el) + 1;
118
+ if (idx > 0) nth = ':nth-of-type(' + idx + ')';
119
+ }
120
+ }
121
+ return tag + classPart + nth;
122
+ }
123
+
124
+ // --- curated computed-style subset for the pick report ---
125
+ var STYLE_KEYS = [
126
+ 'color', 'backgroundColor', 'fontSize', 'fontWeight', 'fontFamily', 'lineHeight',
127
+ 'padding', 'margin', 'borderRadius', 'borderColor', 'borderWidth', 'display', 'boxShadow'
128
+ ];
129
+ function styleSubset(el) {
130
+ var out = {};
131
+ var cs = window.getComputedStyle ? window.getComputedStyle(el) : null;
132
+ if (!cs) return out;
133
+ for (var i = 0; i < STYLE_KEYS.length; i++) out[STYLE_KEYS[i]] = cs[STYLE_KEYS[i]];
134
+ return out;
135
+ }
136
+
137
+ function report(el) {
138
+ var r = el.getBoundingClientRect ? el.getBoundingClientRect() : {};
139
+ var classList = [];
140
+ if (el.classList) for (var i = 0; i < el.classList.length; i++) classList.push(el.classList[i]);
141
+ var v = el.getAttribute(DATA_ATTR);
142
+ return {
143
+ selector: buildSelector(el),
144
+ tagName: (el.tagName || '').toLowerCase(),
145
+ classList: classList,
146
+ boundingRect: {
147
+ x: r.x, y: r.y, width: r.width, height: r.height,
148
+ top: r.top, right: r.right, bottom: r.bottom, left: r.left
149
+ },
150
+ computedStyle: styleSubset(el),
151
+ variant: v == null ? null : Number(v)
152
+ };
153
+ }
154
+
155
+ var state = {
156
+ __installed: true,
157
+ picked: null, // last picked element
158
+ lastReport: null, // last pick report (read by the agent after a click)
159
+ originals: new WeakMap() // element -> {style, html} captured before first swap
160
+ };
161
+
162
+ // --- PICK: one-shot capture click handler ---
163
+ function onPick(ev) {
164
+ var el = ev.target;
165
+ if (!el || el.nodeType !== 1) return;
166
+ ev.preventDefault();
167
+ ev.stopPropagation();
168
+ state.picked = el;
169
+ state.lastReport = report(el);
170
+ document.removeEventListener('click', onPick, true);
171
+ state.picking = false;
172
+ return state.lastReport;
173
+ }
174
+ state.pick = function () {
175
+ state.picking = true;
176
+ document.addEventListener('click', onPick, true);
177
+ return true;
178
+ };
179
+ state.getLastPick = function () { return state.lastReport; };
180
+
181
+ // --- SWAP: apply / read a variant on the picked (or given) element ---
182
+ function captureOriginal(el) {
183
+ if (!state.originals.has(el)) {
184
+ state.originals.set(el, { style: el.getAttribute('style'), html: el.innerHTML });
185
+ }
186
+ }
187
+ // variant = {n:Number, style?:Object, html?:String}
188
+ state.swapVariant = function (variant, el) {
189
+ var target = el || state.picked;
190
+ if (!target || !variant) return null;
191
+ captureOriginal(target);
192
+ target.setAttribute(DATA_ATTR, String(variant.n));
193
+ if (variant.style && typeof variant.style === 'object') {
194
+ for (var k in variant.style) if (Object.prototype.hasOwnProperty.call(variant.style, k)) {
195
+ target.style[k] = variant.style[k];
196
+ }
197
+ }
198
+ if (typeof variant.html === 'string') target.innerHTML = variant.html;
199
+ return Number(target.getAttribute(DATA_ATTR));
200
+ };
201
+ state.currentVariant = function (el) {
202
+ var target = el || state.picked;
203
+ if (!target) return null;
204
+ var v = target.getAttribute(DATA_ATTR);
205
+ return v == null ? null : Number(v);
206
+ };
207
+ state.revert = function (el) {
208
+ var target = el || state.picked;
209
+ if (!target) return false;
210
+ var orig = state.originals.get(target);
211
+ if (orig) {
212
+ if (orig.style == null) target.removeAttribute('style'); else target.setAttribute('style', orig.style);
213
+ target.innerHTML = orig.html;
214
+ }
215
+ target.removeAttribute(DATA_ATTR);
216
+ return true;
217
+ };
218
+
219
+ // expose the pure helper for assertions / debugging
220
+ state.buildSelector = buildSelector;
221
+
222
+ window[GLOBAL_KEY] = state;
223
+ return state;
224
+ })();
225
+ `;
226
+
227
+ module.exports = {
228
+ RUNTIME_JS,
229
+ pickReportShape,
230
+ buildSelector,
231
+ DATA_ATTR,
232
+ GLOBAL_KEY,
233
+ };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/live/scope-guard.cjs — Phase 47 (Live Mode) write-scope guard.
4
+ *
5
+ * `/gdd:live` lets the agent rewrite the source files behind a picked DOM
6
+ * element. To keep that powerful loop safe, EVERY write the live session makes
7
+ * must be inside an explicitly enumerated allow-set:
8
+ *
9
+ * (a) the session's own bookkeeping: anything under `.design/live-sessions/`
10
+ * and `.design/telemetry/` (relative to projectRoot), and
11
+ * (b) the `implicated` source files — the concrete files the picked element
12
+ * maps to (passed in by the caller from the element→source mapping).
13
+ *
14
+ * Anything else — a random repo file, a `package.json`, a `..` escape out of the
15
+ * project — is rejected. This is the runtime backstop for the same intent the
16
+ * Editor/Write protected-paths list expresses at author time.
17
+ *
18
+ * Resolution rules:
19
+ * - All paths are resolved to absolute with path.resolve so `..` segments are
20
+ * collapsed BEFORE comparison (a `../../etc/passwd` can never sneak past a
21
+ * string-prefix check).
22
+ * - Containment uses a normalized, separator-terminated prefix so that
23
+ * `.design/live-sessions-evil/x` does NOT count as inside
24
+ * `.design/live-sessions/` (sibling-directory false positive).
25
+ * - On case-insensitive / Windows filesystems we still compare case-sensitive
26
+ * prefixes after resolve; the implicated set is matched by exact resolved
27
+ * path, which is the conservative choice (a guard that is too strict fails
28
+ * closed, never open).
29
+ *
30
+ * Pure, dependency-free CommonJS (`path` only — no `fs`, no network, no clock).
31
+ * Cross-platform via path.resolve / path.join.
32
+ */
33
+
34
+ const path = require('path');
35
+
36
+ /** The two project-relative directory roots always writable by a live session. */
37
+ const ALWAYS_ALLOWED_SUBDIRS = Object.freeze([
38
+ path.join('.design', 'live-sessions'),
39
+ path.join('.design', 'telemetry'),
40
+ ]);
41
+
42
+ /**
43
+ * True when `child` is the same path as, or nested inside, `parentDir`.
44
+ * Both inputs must already be absolute + resolved. Uses a separator-terminated
45
+ * prefix so sibling dirs that share a name prefix are NOT treated as inside.
46
+ */
47
+ function isWithin(parentDir, child) {
48
+ if (child === parentDir) return true;
49
+ const withSep = parentDir.endsWith(path.sep) ? parentDir : parentDir + path.sep;
50
+ return child.startsWith(withSep);
51
+ }
52
+
53
+ /**
54
+ * Normalize the allowed write-set for a live session into a flat, resolved
55
+ * structure the predicates can test against.
56
+ *
57
+ * @param {object} args
58
+ * @param {string} args.projectRoot project root (required to anchor the always-allowed dirs)
59
+ * @param {Array<string>} [args.implicated] source files the picked element maps to
60
+ * @returns {{ dirs: string[], files: Set<string> }}
61
+ * `dirs` — resolved directory prefixes any descendant of which is allowed.
62
+ * `files` — resolved exact file paths that are allowed.
63
+ */
64
+ function enumerateScope(args = {}) {
65
+ const { projectRoot } = args;
66
+ if (!projectRoot) throw new TypeError('enumerateScope: projectRoot is required');
67
+ const root = path.resolve(projectRoot);
68
+
69
+ const dirs = ALWAYS_ALLOWED_SUBDIRS.map((rel) => path.resolve(root, rel));
70
+
71
+ const files = new Set();
72
+ const implicated = Array.isArray(args.implicated) ? args.implicated : [];
73
+ for (const f of implicated) {
74
+ if (f == null || !String(f).length) continue;
75
+ // Implicated paths may be absolute or relative-to-projectRoot; resolve both
76
+ // against the root so callers can pass either form.
77
+ files.add(path.resolve(root, String(f)));
78
+ }
79
+ return { dirs, files };
80
+ }
81
+
82
+ /**
83
+ * Whether `targetPath` is inside the enumerated scope.
84
+ *
85
+ * @param {object} args
86
+ * @param {string} args.projectRoot
87
+ * @param {string} args.targetPath the path about to be written
88
+ * @param {Array<string>} [args.implicated] element→source files
89
+ * @returns {boolean}
90
+ */
91
+ function isInScope(args = {}) {
92
+ const { projectRoot, targetPath } = args;
93
+ if (!projectRoot) throw new TypeError('isInScope: projectRoot is required');
94
+ if (targetPath == null || !String(targetPath).length) {
95
+ throw new TypeError('isInScope: targetPath is required');
96
+ }
97
+ const root = path.resolve(projectRoot);
98
+ const target = path.resolve(root, String(targetPath));
99
+ const { dirs, files } = enumerateScope({ projectRoot, implicated: args.implicated });
100
+
101
+ if (files.has(target)) return true;
102
+ for (const d of dirs) {
103
+ if (isWithin(d, target)) return true;
104
+ }
105
+ return false;
106
+ }
107
+
108
+ /**
109
+ * Throw unless `targetPath` is inside the enumerated scope. The error message
110
+ * enumerates the allowed set so a violation is diagnosable.
111
+ *
112
+ * @param {object} args same shape as isInScope
113
+ * @returns {{ targetPath: string, resolved: string }} on success
114
+ */
115
+ function assertInScope(args = {}) {
116
+ const { projectRoot, targetPath } = args;
117
+ if (!projectRoot) throw new TypeError('assertInScope: projectRoot is required');
118
+ if (targetPath == null || !String(targetPath).length) {
119
+ throw new TypeError('assertInScope: targetPath is required');
120
+ }
121
+ if (isInScope(args)) {
122
+ const root = path.resolve(projectRoot);
123
+ return { targetPath: String(targetPath), resolved: path.resolve(root, String(targetPath)) };
124
+ }
125
+ const root = path.resolve(projectRoot);
126
+ const resolved = path.resolve(root, String(targetPath));
127
+ const { dirs, files } = enumerateScope({ projectRoot, implicated: args.implicated });
128
+ const allowed = [
129
+ ...dirs.map((d) => `${d}${path.sep}* (always-allowed)`),
130
+ ...[...files].map((f) => `${f} (implicated)`),
131
+ ];
132
+ throw new Error(
133
+ `scope-guard: refusing to write "${resolved}" — outside the live-session write scope.\n` +
134
+ `Allowed:\n ${allowed.length ? allowed.join('\n ') : '(none)'}`,
135
+ );
136
+ }
137
+
138
+ module.exports = {
139
+ assertInScope,
140
+ isInScope,
141
+ enumerateScope,
142
+ // exported for callers + tests
143
+ isWithin,
144
+ ALWAYS_ALLOWED_SUBDIRS,
145
+ };