@hegemonart/get-design-done 1.21.0 → 1.23.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 (39) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +184 -0
  4. package/hooks/_hook-emit.js +81 -0
  5. package/hooks/gdd-bash-guard.js +8 -0
  6. package/hooks/gdd-decision-injector.js +2 -0
  7. package/hooks/gdd-protected-paths.js +8 -0
  8. package/hooks/gdd-trajectory-capture.js +64 -0
  9. package/hooks/hooks.json +9 -0
  10. package/package.json +7 -2
  11. package/reference/output-contracts/planner-decision.schema.json +94 -0
  12. package/reference/output-contracts/verifier-decision.schema.json +66 -0
  13. package/scripts/cli/gdd-events.mjs +283 -0
  14. package/scripts/lib/audit-aggregator/index.cjs +219 -0
  15. package/scripts/lib/connection-probe/index.cjs +263 -0
  16. package/scripts/lib/design-solidify.mjs +265 -0
  17. package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
  18. package/scripts/lib/design-tokens/css-vars.cjs +55 -0
  19. package/scripts/lib/design-tokens/figma.cjs +121 -0
  20. package/scripts/lib/design-tokens/index.cjs +100 -0
  21. package/scripts/lib/design-tokens/js-const.cjs +107 -0
  22. package/scripts/lib/design-tokens/tailwind.cjs +98 -0
  23. package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
  24. package/scripts/lib/domain-primitives/nng.cjs +136 -0
  25. package/scripts/lib/domain-primitives/wcag.cjs +166 -0
  26. package/scripts/lib/event-chain.cjs +177 -0
  27. package/scripts/lib/event-stream/index.ts +20 -0
  28. package/scripts/lib/event-stream/reader.ts +139 -0
  29. package/scripts/lib/event-stream/types.ts +155 -1
  30. package/scripts/lib/event-stream/writer.ts +65 -8
  31. package/scripts/lib/parse-contract.cjs +168 -0
  32. package/scripts/lib/redact.cjs +122 -0
  33. package/scripts/lib/reference-resolver.cjs +184 -0
  34. package/scripts/lib/touches-analyzer/index.cjs +201 -0
  35. package/scripts/lib/touches-pattern-miner.cjs +195 -0
  36. package/scripts/lib/trajectory/index.cjs +126 -0
  37. package/scripts/lib/transports/ws.cjs +179 -0
  38. package/scripts/lib/visual-baseline/diff.cjs +137 -0
  39. package/scripts/lib/visual-baseline/index.cjs +139 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * connection-probe/index.cjs — code-level connection liveness probe
3
+ * (Plan 22-08).
4
+ *
5
+ * Replaces today's per-connection ad-hoc probe bash snippets in
6
+ * `connections/` with one typed primitive. Used by Phase 21
7
+ * pipeline-runner and Phase 22 reflector.
8
+ *
9
+ * Contract:
10
+ * probe({
11
+ * name: 'figma' | 'pinterest' | … // free-form connection id
12
+ * cmd: async () => boolean | Truthy // probe action
13
+ * timeout: number ms // default 5000
14
+ * retries: number // default 3 attempts total
15
+ * fallback: async () => unknown // optional degraded path
16
+ * }) → {
17
+ * status: 'ok' | 'degraded' | 'down'
18
+ * latency_ms: number
19
+ * attempts: number
20
+ * fallback_used: boolean
21
+ * error?: string // last error message if any
22
+ * }
23
+ *
24
+ * State persistence:
25
+ * * `.design/telemetry/connection-state.json` records `{name → status}`
26
+ * across runs.
27
+ * * On every probe, if the new status differs from cached, emit a
28
+ * `connection.status_change` event via the event-stream bus and
29
+ * overwrite the cached value atomically (write to .tmp + rename).
30
+ *
31
+ * Backoff:
32
+ * * Uses `jittered-backoff.cjs` — `delayMs(attempt)` between retries.
33
+ *
34
+ * The probe `cmd` is awaited with a Promise.race against a timeout. On
35
+ * fulfilment with truthy → ok. On rejection or falsy → fail-this-attempt;
36
+ * retry until exhausted. After full-fail, if `fallback` is supplied,
37
+ * runs it and reports `degraded` + `fallback_used: true`.
38
+ */
39
+
40
+ 'use strict';
41
+
42
+ const { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } = require('node:fs');
43
+ const { dirname, isAbsolute, resolve, join } = require('node:path');
44
+
45
+ const { delayMs } = require('../jittered-backoff.cjs');
46
+
47
+ const DEFAULT_STATE_PATH = '.design/telemetry/connection-state.json';
48
+
49
+ /**
50
+ * Resolve the connection-state file path against a base dir.
51
+ * @param {{baseDir?: string, statePath?: string}} [opts]
52
+ */
53
+ function statePathFor(opts = {}) {
54
+ const raw = opts.statePath ?? DEFAULT_STATE_PATH;
55
+ if (isAbsolute(raw)) return raw;
56
+ return resolve(opts.baseDir ?? process.cwd(), raw);
57
+ }
58
+
59
+ /**
60
+ * Load + return the cached state object (or `{}` if absent / corrupt).
61
+ * @param {string} path
62
+ * @returns {Record<string, string>}
63
+ */
64
+ function loadState(path) {
65
+ if (!existsSync(path)) return {};
66
+ try {
67
+ return JSON.parse(readFileSync(path, 'utf8'));
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Atomic state write: write to `.tmp` sibling, rename. Renames are
75
+ * atomic on POSIX and at least crash-safe on Windows for same-volume
76
+ * targets.
77
+ * @param {string} path
78
+ * @param {Record<string, string>} state
79
+ */
80
+ function saveState(path, state) {
81
+ try {
82
+ mkdirSync(dirname(path), { recursive: true });
83
+ const tmp = path + '.tmp';
84
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
85
+ renameSync(tmp, path);
86
+ } catch (err) {
87
+ try {
88
+ process.stderr.write(
89
+ `[connection-probe] state write failed: ${err && err.message ? err.message : String(err)}\n`,
90
+ );
91
+ } catch {
92
+ /* swallow */
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Race `promise` against a timeout. Rejects with `TimeoutError` after
99
+ * `ms` if the promise hasn't settled.
100
+ *
101
+ * @template T
102
+ * @param {Promise<T>} promise
103
+ * @param {number} ms
104
+ * @returns {Promise<T>}
105
+ */
106
+ function withTimeout(promise, ms) {
107
+ return new Promise((resolve, reject) => {
108
+ const timer = setTimeout(() => {
109
+ const err = new Error(`probe timed out after ${ms}ms`);
110
+ err.code = 'PROBE_TIMEOUT';
111
+ reject(err);
112
+ }, ms);
113
+ promise.then(
114
+ (v) => {
115
+ clearTimeout(timer);
116
+ resolve(v);
117
+ },
118
+ (e) => {
119
+ clearTimeout(timer);
120
+ reject(e);
121
+ },
122
+ );
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Run the probe with retries + optional fallback. Resolves to a
128
+ * structured outcome; never rejects.
129
+ *
130
+ * @param {{
131
+ * name: string,
132
+ * cmd: () => Promise<unknown>,
133
+ * timeout?: number,
134
+ * retries?: number,
135
+ * fallback?: () => Promise<unknown>,
136
+ * baseDir?: string,
137
+ * statePath?: string,
138
+ * emit?: (ev: unknown) => void,
139
+ * }} opts
140
+ * @returns {Promise<{
141
+ * status: 'ok' | 'degraded' | 'down',
142
+ * latency_ms: number,
143
+ * attempts: number,
144
+ * fallback_used: boolean,
145
+ * error?: string,
146
+ * }>}
147
+ */
148
+ async function probe(opts) {
149
+ if (!opts || typeof opts.name !== 'string' || opts.name.length === 0) {
150
+ throw new TypeError('probe: name (string) required');
151
+ }
152
+ if (typeof opts.cmd !== 'function') {
153
+ throw new TypeError('probe: cmd (async fn) required');
154
+ }
155
+ const timeout = opts.timeout ?? 5000;
156
+ const retries = Math.max(1, opts.retries ?? 3);
157
+ const path = statePathFor(opts);
158
+
159
+ const start = Date.now();
160
+ let attempts = 0;
161
+ let lastError;
162
+
163
+ for (let i = 0; i < retries; i++) {
164
+ attempts += 1;
165
+ try {
166
+ const result = await withTimeout(Promise.resolve(opts.cmd()), timeout);
167
+ if (result) {
168
+ const outcome = {
169
+ status: /** @type {'ok'} */ ('ok'),
170
+ latency_ms: Date.now() - start,
171
+ attempts,
172
+ fallback_used: false,
173
+ };
174
+ await recordTransition(opts.name, outcome.status, path, opts.emit);
175
+ return outcome;
176
+ }
177
+ // falsy = soft-fail; retry
178
+ lastError = new Error('probe returned falsy');
179
+ } catch (err) {
180
+ lastError = err;
181
+ }
182
+ if (i < retries - 1) {
183
+ await sleep(delayMs(i));
184
+ }
185
+ }
186
+
187
+ // All retries failed. Try fallback if supplied.
188
+ if (typeof opts.fallback === 'function') {
189
+ try {
190
+ await opts.fallback();
191
+ const outcome = {
192
+ status: /** @type {'degraded'} */ ('degraded'),
193
+ latency_ms: Date.now() - start,
194
+ attempts,
195
+ fallback_used: true,
196
+ error: lastError && lastError.message ? lastError.message : String(lastError),
197
+ };
198
+ await recordTransition(opts.name, outcome.status, path, opts.emit);
199
+ return outcome;
200
+ } catch {
201
+ /* fall through to down */
202
+ }
203
+ }
204
+
205
+ const outcome = {
206
+ status: /** @type {'down'} */ ('down'),
207
+ latency_ms: Date.now() - start,
208
+ attempts,
209
+ fallback_used: false,
210
+ error: lastError && lastError.message ? lastError.message : String(lastError),
211
+ };
212
+ await recordTransition(opts.name, outcome.status, path, opts.emit);
213
+ return outcome;
214
+ }
215
+
216
+ /** Sleep for `ms` milliseconds. */
217
+ function sleep(ms) {
218
+ return new Promise((r) => setTimeout(r, ms));
219
+ }
220
+
221
+ /**
222
+ * Compare against cached state. If status differs, emit a
223
+ * `connection.status_change` event (when an `emit` callback is supplied)
224
+ * and overwrite the cached value atomically.
225
+ *
226
+ * @param {string} name
227
+ * @param {string} status
228
+ * @param {string} statePath
229
+ * @param {undefined | ((ev: unknown) => void)} emit
230
+ */
231
+ async function recordTransition(name, status, statePath, emit) {
232
+ const state = loadState(statePath);
233
+ const previous = state[name];
234
+ if (previous === status) return; // no transition
235
+ state[name] = status;
236
+ saveState(statePath, state);
237
+ if (typeof emit === 'function') {
238
+ try {
239
+ emit({
240
+ type: 'connection.status_change',
241
+ timestamp: new Date().toISOString(),
242
+ sessionId: process.env.GDD_SESSION_ID || 'unknown',
243
+ payload: { name, from: previous ?? 'unknown', to: status },
244
+ });
245
+ } catch (err) {
246
+ try {
247
+ process.stderr.write(
248
+ `[connection-probe] emit failed: ${err && err.message ? err.message : String(err)}\n`,
249
+ );
250
+ } catch {
251
+ /* swallow */
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ module.exports = {
258
+ probe,
259
+ statePathFor,
260
+ loadState,
261
+ saveState,
262
+ DEFAULT_STATE_PATH,
263
+ };
@@ -0,0 +1,265 @@
1
+ /**
2
+ * design-solidify.mjs — solidify-with-rollback gate (Plan 23-02).
3
+ *
4
+ * Code-level gate that runs the validation triplet for a task and, on
5
+ * any failure, rolls the working tree back via git stash (configurable)
6
+ * and appends a `solidify.rollback` event onto the Phase 22 causal chain.
7
+ *
8
+ * Replaces the prompt-encoded "stash if it broke" instruction in today's
9
+ * solidify agents with a typed, testable function.
10
+ *
11
+ * Why .mjs, not .ts:
12
+ * * Node 24 + Windows + .mjs dynamic-importing .ts triggers
13
+ * STATUS_STACK_BUFFER_OVERRUN (Phase 22 lesson). Keep this file as
14
+ * plain .mjs with a CJS test wrapper.
15
+ *
16
+ * Usage:
17
+ * import { solidify } from './design-solidify.mjs';
18
+ * const result = await solidify({ taskId: '23-02' });
19
+ * // result.outcome === 'pass' | 'fail'
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync } from 'node:fs';
24
+ import { dirname, resolve, join } from 'node:path';
25
+ import { createRequire } from 'node:module';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ // Anchor on the .mjs file's own directory — NOT on the caller's cwd —
29
+ // because callers pass in arbitrary `cwd` values (test scaffolds,
30
+ // sub-repos) where event-chain.cjs is unreachable. Walk up from
31
+ // scripts/lib/ to repo root.
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ function _findRepoRoot(start) {
34
+ let dir = start;
35
+ for (let i = 0; i < 12; i++) {
36
+ if (existsSync(join(dir, 'package.json'))) return dir;
37
+ const parent = dirname(dir);
38
+ if (parent === dir) break;
39
+ dir = parent;
40
+ }
41
+ return start;
42
+ }
43
+ const PLUGIN_ROOT = _findRepoRoot(dirname(__filename));
44
+ const _nodeRequire = createRequire(join(PLUGIN_ROOT, 'package.json'));
45
+ const { appendChainEvent } = _nodeRequire(
46
+ resolve(PLUGIN_ROOT, 'scripts/lib/event-chain.cjs'),
47
+ );
48
+
49
+ /**
50
+ * @typedef {Object} SolidifyValidation
51
+ * @property {string} name
52
+ * @property {string} cmd
53
+ * @property {string[]} args
54
+ * @property {number} [timeout_ms]
55
+ */
56
+
57
+ /**
58
+ * @typedef {Object} SolidifyOptions
59
+ * @property {string} taskId
60
+ * @property {SolidifyValidation[]} [validations]
61
+ * @property {'stash'|'hard'|'none'} [rollback]
62
+ * @property {string} [cwd]
63
+ * @property {string[]} [decisionRefs]
64
+ * @property {string} [parentEventId]
65
+ * @property {(ev: object) => void} [emit]
66
+ * @property {string} [chainPath]
67
+ */
68
+
69
+ /**
70
+ * @typedef {Object} SolidifyStep
71
+ * @property {string} name
72
+ * @property {'pass'|'fail'} status
73
+ * @property {string} [stdout]
74
+ * @property {string} [stderr]
75
+ * @property {number|null} [code]
76
+ * @property {string|null} [signal]
77
+ */
78
+
79
+ /**
80
+ * @typedef {Object} SolidifyResult
81
+ * @property {'pass'|'fail'} outcome
82
+ * @property {SolidifyStep[]} steps
83
+ * @property {'stash'|'hard'|'none'|'skipped'} [rolledBackVia]
84
+ * @property {string} eventId
85
+ * @property {string} [stashRef]
86
+ */
87
+
88
+ /**
89
+ * Build the default validation triplet for a task. Caller may override
90
+ * by supplying `opts.validations` directly.
91
+ *
92
+ * @param {string} taskId
93
+ * @returns {SolidifyValidation[]}
94
+ */
95
+ function defaultValidations(taskId) {
96
+ return [
97
+ { name: 'typecheck', cmd: 'npm', args: ['run', 'typecheck'], timeout_ms: 120_000 },
98
+ { name: 'build', cmd: 'npm', args: ['run', 'build'], timeout_ms: 300_000 },
99
+ {
100
+ name: 'targeted-test',
101
+ cmd: 'npm',
102
+ args: ['test', '--', '--testPathPattern', String(taskId)],
103
+ timeout_ms: 120_000,
104
+ },
105
+ ];
106
+ }
107
+
108
+ /**
109
+ * Run one validation step via spawnSync. Always returns a step record;
110
+ * never throws.
111
+ *
112
+ * @param {SolidifyValidation} v
113
+ * @param {string} cwd
114
+ * @returns {SolidifyStep}
115
+ */
116
+ function runStep(v, cwd) {
117
+ const r = spawnSync(v.cmd, v.args, {
118
+ cwd,
119
+ encoding: 'utf8',
120
+ shell: false,
121
+ timeout: v.timeout_ms ?? 120_000,
122
+ });
123
+ const status = r.status === 0 && !r.error ? 'pass' : 'fail';
124
+ return {
125
+ name: v.name,
126
+ status,
127
+ stdout: r.stdout,
128
+ stderr: r.stderr || (r.error ? String(r.error.message || r.error) : undefined),
129
+ code: r.status,
130
+ signal: r.signal,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Roll the tree back per the configured strategy. Never throws —
136
+ * returns the resolved verb (or 'skipped' if the strategy could not be
137
+ * applied) and an optional stash ref.
138
+ *
139
+ * @param {'stash'|'hard'|'none'} mode
140
+ * @param {string} cwd
141
+ * @param {string} taskId
142
+ * @returns {{via: 'stash'|'hard'|'none'|'skipped', stashRef?: string}}
143
+ */
144
+ function rollback(mode, cwd, taskId) {
145
+ if (mode === 'none') return { via: 'none' };
146
+ if (!existsSync(join(cwd, '.git'))) return { via: 'skipped' };
147
+ const ts = new Date().toISOString();
148
+
149
+ if (mode === 'stash') {
150
+ const r = spawnSync(
151
+ 'git',
152
+ ['stash', 'push', '-u', '-m', `solidify-rollback:${taskId}:${ts}`],
153
+ { cwd, encoding: 'utf8', shell: false, timeout: 30_000 },
154
+ );
155
+ if (r.status !== 0) return { via: 'skipped' };
156
+ const refRes = spawnSync('git', ['stash', 'list', '-1', '--format=%gd'], {
157
+ cwd,
158
+ encoding: 'utf8',
159
+ shell: false,
160
+ timeout: 10_000,
161
+ });
162
+ const stashRef = refRes.status === 0 ? refRes.stdout.trim() : undefined;
163
+ return { via: 'stash', stashRef };
164
+ }
165
+
166
+ if (mode === 'hard') {
167
+ const r = spawnSync('git', ['reset', '--hard', 'HEAD'], {
168
+ cwd,
169
+ encoding: 'utf8',
170
+ shell: false,
171
+ timeout: 30_000,
172
+ });
173
+ if (r.status !== 0) return { via: 'skipped' };
174
+ return { via: 'hard' };
175
+ }
176
+
177
+ return { via: 'skipped' };
178
+ }
179
+
180
+ /**
181
+ * Run the solidify gate.
182
+ *
183
+ * @param {SolidifyOptions} opts
184
+ * @returns {Promise<SolidifyResult>}
185
+ */
186
+ export async function solidify(opts) {
187
+ if (!opts || typeof opts.taskId !== 'string' || opts.taskId.length === 0) {
188
+ throw new TypeError('solidify: opts.taskId is required (non-empty string)');
189
+ }
190
+ const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd();
191
+ const validations =
192
+ Array.isArray(opts.validations) && opts.validations.length > 0
193
+ ? opts.validations
194
+ : defaultValidations(opts.taskId);
195
+ const mode = opts.rollback ?? 'stash';
196
+
197
+ /** @type {SolidifyStep[]} */
198
+ const steps = [];
199
+ /** @type {SolidifyStep|null} */
200
+ let failingStep = null;
201
+ for (const v of validations) {
202
+ const step = runStep(v, cwd);
203
+ steps.push(step);
204
+ if (step.status === 'fail') {
205
+ failingStep = step;
206
+ break;
207
+ }
208
+ }
209
+
210
+ let rolledBackVia = 'none';
211
+ let stashRef;
212
+ if (failingStep && mode !== 'none') {
213
+ const r = rollback(mode, cwd, opts.taskId);
214
+ rolledBackVia = r.via;
215
+ stashRef = r.stashRef;
216
+ } else if (failingStep && mode === 'none') {
217
+ rolledBackVia = 'none';
218
+ }
219
+
220
+ const chainEvent = {
221
+ parent_event_id: opts.parentEventId ?? null,
222
+ agent: 'design-solidify',
223
+ decision_refs: opts.decisionRefs ?? [],
224
+ outcome: failingStep ? 'rolled-back' : 'pass',
225
+ task_id: opts.taskId,
226
+ rolled_back_via: rolledBackVia,
227
+ steps: steps.map((s) => ({ name: s.name, status: s.status, code: s.code })),
228
+ };
229
+ if (failingStep) {
230
+ chainEvent.rollback_reason = `${failingStep.name} failed (code=${failingStep.code})`;
231
+ }
232
+ if (stashRef) chainEvent.stash_ref = stashRef;
233
+ if (opts.chainPath) chainEvent.path = opts.chainPath;
234
+ if (opts.cwd) chainEvent.baseDir = opts.cwd;
235
+
236
+ const eventId = appendChainEvent(chainEvent);
237
+
238
+ if (typeof opts.emit === 'function') {
239
+ try {
240
+ opts.emit({
241
+ type: 'solidify.rollback',
242
+ timestamp: new Date().toISOString(),
243
+ sessionId: process.env.GDD_SESSION_ID || 'unknown',
244
+ payload: {
245
+ task_id: opts.taskId,
246
+ outcome: chainEvent.outcome,
247
+ rolled_back_via: rolledBackVia,
248
+ failing_step: failingStep ? failingStep.name : null,
249
+ },
250
+ });
251
+ } catch {
252
+ /* swallow — emission must never bubble */
253
+ }
254
+ }
255
+
256
+ /** @type {SolidifyResult} */
257
+ const result = {
258
+ outcome: failingStep ? 'fail' : 'pass',
259
+ steps,
260
+ rolledBackVia,
261
+ eventId,
262
+ };
263
+ if (stashRef) result.stashRef = stashRef;
264
+ return result;
265
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * _js-harness.cjs — child process worker for js-const.cjs.
4
+ *
5
+ * Reads argv[2] as a file path, attempts to require() it (CJS first,
6
+ * then dynamic import for ESM), extracts tokens per recognised shapes,
7
+ * prints `{tokens: ..., error?: string}` JSON on stdout, exits 0.
8
+ *
9
+ * Never throws — errors are returned as JSON so the parent can render
10
+ * them as warnings.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const target = process.argv[2];
16
+
17
+ function main() {
18
+ if (!target) {
19
+ return { tokens: {}, error: 'no-target' };
20
+ }
21
+ // Try CJS require first.
22
+ try {
23
+ // eslint-disable-next-line node/no-missing-require, global-require
24
+ const mod = require(target);
25
+ return { tokens: extract(mod) };
26
+ } catch (cjsErr) {
27
+ // Fall through to dynamic import.
28
+ }
29
+ // ESM dynamic import — async; wrap in IIFE.
30
+ return new Promise((resolve) => {
31
+ const url = require('node:url').pathToFileURL(target).href;
32
+ import(url).then(
33
+ (mod) => {
34
+ const candidate = mod.tokens ?? mod.default?.tokens ?? mod.default ?? mod;
35
+ resolve({ tokens: extract(candidate) });
36
+ },
37
+ (err) => resolve({ tokens: {}, error: `import-failed: ${err.message || String(err)}` }),
38
+ );
39
+ });
40
+ }
41
+
42
+ function extract(mod) {
43
+ if (mod && typeof mod === 'object') {
44
+ if (mod.tokens && typeof mod.tokens === 'object') return mod.tokens;
45
+ if (mod.default && typeof mod.default === 'object' && mod.default.tokens) {
46
+ return mod.default.tokens;
47
+ }
48
+ return mod;
49
+ }
50
+ return {};
51
+ }
52
+
53
+ Promise.resolve(main()).then(
54
+ (result) => {
55
+ try {
56
+ process.stdout.write(JSON.stringify(result));
57
+ } catch {
58
+ process.stdout.write(JSON.stringify({ tokens: {}, error: 'stringify-failed' }));
59
+ }
60
+ process.exit(0);
61
+ },
62
+ (err) => {
63
+ process.stdout.write(JSON.stringify({ tokens: {}, error: String(err) }));
64
+ process.exit(0);
65
+ },
66
+ );
@@ -0,0 +1,55 @@
1
+ /**
2
+ * design-tokens/css-vars.cjs — extract custom-property declarations
3
+ * from CSS / SCSS source (Plan 23-08).
4
+ *
5
+ * Parses any `--name: value;` declarations regardless of selector
6
+ * context. Last-write-wins on duplicate names. Block comments stripped.
7
+ * Strips the `--` prefix in the returned token map.
8
+ *
9
+ * Not supported (caller-warned via warnings):
10
+ * * SCSS `$var: value;` syntax — use the JS reader for SCSS exports
11
+ * * calc() / var() reference resolution — values returned verbatim
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('node:fs');
17
+ const path = require('node:path');
18
+
19
+ const CUSTOM_PROP_RE = /--([A-Za-z0-9][A-Za-z0-9_-]*)\s*:\s*([^;]+?)\s*(?=[;}])/g;
20
+ const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
21
+ const SCSS_VAR_RE = /^\s*\$[A-Za-z][A-Za-z0-9_-]*\s*:/m;
22
+
23
+ /**
24
+ * Read a CSS/SCSS file and extract `--token: value;` declarations.
25
+ *
26
+ * @param {string} filePath
27
+ * @returns {{tokens: Record<string, string>, source: string, format: 'css-vars', warnings: string[]}}
28
+ */
29
+ function readCssVars(filePath) {
30
+ const abs = path.resolve(filePath);
31
+ const raw = fs.readFileSync(abs, 'utf8');
32
+ const stripped = raw.replace(BLOCK_COMMENT_RE, '');
33
+ /** @type {string[]} */
34
+ const warnings = [];
35
+ if (SCSS_VAR_RE.test(stripped)) {
36
+ warnings.push('scss-vars-detected: $var: ... declarations are not parsed by css-vars.cjs');
37
+ }
38
+ /** @type {Record<string, string>} */
39
+ const tokens = {};
40
+ CUSTOM_PROP_RE.lastIndex = 0;
41
+ let m;
42
+ while ((m = CUSTOM_PROP_RE.exec(stripped)) !== null) {
43
+ const name = m[1];
44
+ const value = m[2].trim();
45
+ tokens[name] = value;
46
+ }
47
+ return {
48
+ tokens,
49
+ source: abs,
50
+ format: 'css-vars',
51
+ warnings,
52
+ };
53
+ }
54
+
55
+ module.exports = { readCssVars };