@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +184 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +7 -2
- package/reference/output-contracts/planner-decision.schema.json +94 -0
- package/reference/output-contracts/verifier-decision.schema.json +66 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/lib/audit-aggregator/index.cjs +219 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/design-solidify.mjs +265 -0
- package/scripts/lib/design-tokens/_js-harness.cjs +66 -0
- package/scripts/lib/design-tokens/css-vars.cjs +55 -0
- package/scripts/lib/design-tokens/figma.cjs +121 -0
- package/scripts/lib/design-tokens/index.cjs +100 -0
- package/scripts/lib/design-tokens/js-const.cjs +107 -0
- package/scripts/lib/design-tokens/tailwind.cjs +98 -0
- package/scripts/lib/domain-primitives/anti-patterns.cjs +66 -0
- package/scripts/lib/domain-primitives/nng.cjs +136 -0
- package/scripts/lib/domain-primitives/wcag.cjs +166 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +20 -0
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/parse-contract.cjs +168 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/reference-resolver.cjs +184 -0
- package/scripts/lib/touches-analyzer/index.cjs +201 -0
- package/scripts/lib/touches-pattern-miner.cjs +195 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- package/scripts/lib/transports/ws.cjs +179 -0
- package/scripts/lib/visual-baseline/diff.cjs +137 -0
- package/scripts/lib/visual-baseline/index.cjs +139 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/figma.cjs — parse a Figma variable export JSON into
|
|
3
|
+
* a flat token map (Plan 23-08).
|
|
4
|
+
*
|
|
5
|
+
* Consumes the shape returned by mcp__figma__get_variable_defs (and the
|
|
6
|
+
* official Figma Variables Export JSON). Handles either:
|
|
7
|
+
* * `{ variableCollections: { <id>: { name, modes, variables: { <id>: {name, valuesByMode} } } } }`
|
|
8
|
+
* * Already-flattened `{ name: value }` map (passes through with format='figma')
|
|
9
|
+
*
|
|
10
|
+
* Mode handling: when a variable has multiple modes, we emit one token
|
|
11
|
+
* per mode using `<collection>.<varName>.<modeName>`. Single-mode
|
|
12
|
+
* variables emit the bare path.
|
|
13
|
+
*
|
|
14
|
+
* Color values are emitted as `rgba(R, G, B, A)` strings; numeric +
|
|
15
|
+
* string values pass through verbatim.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
23
|
+
function rgbaFor(c) {
|
|
24
|
+
if (typeof c !== 'object' || c === null) return String(c);
|
|
25
|
+
const r = Math.round((Number(c.r) || 0) * 255);
|
|
26
|
+
const g = Math.round((Number(c.g) || 0) * 255);
|
|
27
|
+
const b = Math.round((Number(c.b) || 0) * 255);
|
|
28
|
+
const a = c.a === undefined ? 1 : Number(c.a);
|
|
29
|
+
return a === 1 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function valueToString(v) {
|
|
33
|
+
if (v === null || v === undefined) return '';
|
|
34
|
+
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
|
35
|
+
return String(v);
|
|
36
|
+
}
|
|
37
|
+
if (typeof v === 'object') {
|
|
38
|
+
// Color shape: {r, g, b, a?}.
|
|
39
|
+
if (Object.prototype.hasOwnProperty.call(v, 'r') &&
|
|
40
|
+
Object.prototype.hasOwnProperty.call(v, 'g') &&
|
|
41
|
+
Object.prototype.hasOwnProperty.call(v, 'b')) {
|
|
42
|
+
return rgbaFor(v);
|
|
43
|
+
}
|
|
44
|
+
// Alias / reference — emit the full reference id for the consumer to resolve.
|
|
45
|
+
if (v.type === 'VARIABLE_ALIAS' && v.id) return `var(--${v.id})`;
|
|
46
|
+
return JSON.stringify(v);
|
|
47
|
+
}
|
|
48
|
+
return String(v);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} filePath
|
|
53
|
+
* @returns {{tokens: Record<string, string>, source: string, format: 'figma', warnings: string[]}}
|
|
54
|
+
*/
|
|
55
|
+
function readFigma(filePath) {
|
|
56
|
+
const abs = path.resolve(filePath);
|
|
57
|
+
const raw = fs.readFileSync(abs, 'utf8');
|
|
58
|
+
/** @type {string[]} */
|
|
59
|
+
const warnings = [];
|
|
60
|
+
/** @type {unknown} */
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return {
|
|
66
|
+
tokens: {},
|
|
67
|
+
source: abs,
|
|
68
|
+
format: 'figma',
|
|
69
|
+
warnings: [`json-parse-failed: ${err.message}`],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @type {Record<string, string>} */
|
|
74
|
+
const out = {};
|
|
75
|
+
// Branch 1: variableCollections shape.
|
|
76
|
+
if (parsed && typeof parsed === 'object' && parsed.variableCollections) {
|
|
77
|
+
for (const collId of Object.keys(parsed.variableCollections)) {
|
|
78
|
+
const coll = parsed.variableCollections[collId];
|
|
79
|
+
const collName = coll.name || collId;
|
|
80
|
+
const modes = coll.modes || {};
|
|
81
|
+
// modes might be an array of {modeId, name} or a map.
|
|
82
|
+
const modeNames = Array.isArray(modes)
|
|
83
|
+
? new Map(modes.map((m) => [m.modeId, m.name]))
|
|
84
|
+
: new Map(Object.entries(modes).map(([id, m]) => [id, (m && m.name) || id]));
|
|
85
|
+
const vars = coll.variables || {};
|
|
86
|
+
for (const varId of Object.keys(vars)) {
|
|
87
|
+
const v = vars[varId];
|
|
88
|
+
const varName = v.name || varId;
|
|
89
|
+
const valuesByMode = v.valuesByMode || {};
|
|
90
|
+
const modeIds = Object.keys(valuesByMode);
|
|
91
|
+
if (modeIds.length === 0) {
|
|
92
|
+
warnings.push(`no-modes: ${collName}.${varName}`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (modeIds.length === 1) {
|
|
96
|
+
const key = `${collName}.${varName}`;
|
|
97
|
+
out[key] = valueToString(valuesByMode[modeIds[0]]);
|
|
98
|
+
} else {
|
|
99
|
+
for (const mid of modeIds) {
|
|
100
|
+
const modeName = modeNames.get(mid) || mid;
|
|
101
|
+
out[`${collName}.${varName}.${modeName}`] = valueToString(valuesByMode[mid]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { tokens: out, source: abs, format: 'figma', warnings };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Branch 2: already-flattened bag.
|
|
110
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
111
|
+
for (const k of Object.keys(parsed)) {
|
|
112
|
+
out[k] = valueToString(parsed[k]);
|
|
113
|
+
}
|
|
114
|
+
return { tokens: out, source: abs, format: 'figma', warnings };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
warnings.push('unrecognised-figma-shape');
|
|
118
|
+
return { tokens: out, source: abs, format: 'figma', warnings };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { readFigma };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/index.cjs — facade over the four token readers
|
|
3
|
+
* (Plan 23-08).
|
|
4
|
+
*
|
|
5
|
+
* Auto-detects format from extension + content sniff, dispatches to
|
|
6
|
+
* css-vars / js-const / tailwind / figma. Returns the uniform
|
|
7
|
+
* `{tokens, source, format, warnings}` shape from each reader.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const { readCssVars } = require('./css-vars.cjs');
|
|
16
|
+
const { readJsConst } = require('./js-const.cjs');
|
|
17
|
+
const { readTailwind } = require('./tailwind.cjs');
|
|
18
|
+
const { readFigma } = require('./figma.cjs');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} TokenSet
|
|
22
|
+
* @property {Object<string, string>} tokens
|
|
23
|
+
* @property {string} source
|
|
24
|
+
* @property {'css-vars'|'js-const'|'tailwind'|'figma'} format
|
|
25
|
+
* @property {string[]} [warnings]
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sniff format from a file's extension + a head-snippet of its content.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} filePath
|
|
32
|
+
* @returns {'css-vars'|'js-const'|'tailwind'|'figma'}
|
|
33
|
+
*/
|
|
34
|
+
function detectFormat(filePath) {
|
|
35
|
+
const lower = filePath.toLowerCase();
|
|
36
|
+
if (lower.endsWith('.css') || lower.endsWith('.scss')) return 'css-vars';
|
|
37
|
+
if (
|
|
38
|
+
/tailwind\.config\.(js|cjs|mjs|ts)$/.test(lower) ||
|
|
39
|
+
lower.endsWith('tailwind.config.js')
|
|
40
|
+
) {
|
|
41
|
+
return 'tailwind';
|
|
42
|
+
}
|
|
43
|
+
if (lower.endsWith('.json')) {
|
|
44
|
+
// Sniff: variableCollections → figma, else js-const path with the JSON.
|
|
45
|
+
try {
|
|
46
|
+
const head = fs.readFileSync(filePath, 'utf8').slice(0, 4096);
|
|
47
|
+
if (head.includes('"variableCollections"')) return 'figma';
|
|
48
|
+
} catch {
|
|
49
|
+
/* fall through */
|
|
50
|
+
}
|
|
51
|
+
return 'figma';
|
|
52
|
+
}
|
|
53
|
+
// Default: any other JS/TS file is js-const.
|
|
54
|
+
return 'js-const';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read tokens from a file with auto-detected format.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} filePath
|
|
61
|
+
* @returns {TokenSet}
|
|
62
|
+
*/
|
|
63
|
+
function read(filePath) {
|
|
64
|
+
const format = detectFormat(filePath);
|
|
65
|
+
switch (format) {
|
|
66
|
+
case 'css-vars':
|
|
67
|
+
return readCssVars(filePath);
|
|
68
|
+
case 'tailwind':
|
|
69
|
+
return readTailwind(filePath);
|
|
70
|
+
case 'figma':
|
|
71
|
+
return readFigma(filePath);
|
|
72
|
+
case 'js-const':
|
|
73
|
+
default:
|
|
74
|
+
return readJsConst(filePath);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read multiple files into separate TokenSets.
|
|
80
|
+
*
|
|
81
|
+
* @param {string[]} filePaths
|
|
82
|
+
* @returns {TokenSet[]}
|
|
83
|
+
*/
|
|
84
|
+
function readAll(filePaths) {
|
|
85
|
+
if (!Array.isArray(filePaths)) {
|
|
86
|
+
throw new TypeError('design-tokens: filePaths must be an array');
|
|
87
|
+
}
|
|
88
|
+
return filePaths.map((p) => read(p));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
read,
|
|
93
|
+
readAll,
|
|
94
|
+
detectFormat,
|
|
95
|
+
// re-export for callers that already know the format
|
|
96
|
+
readCssVars,
|
|
97
|
+
readJsConst,
|
|
98
|
+
readTailwind,
|
|
99
|
+
readFigma,
|
|
100
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/js-const.cjs — load token modules via spawned-node
|
|
3
|
+
* subprocess (Plan 23-08).
|
|
4
|
+
*
|
|
5
|
+
* Why subprocess: the project may use ESM, CJS, or .ts (with type-strip
|
|
6
|
+
* runtimes); evaluating user JS in-process risks side effects + version
|
|
7
|
+
* skew. Fork a child node with a tiny harness that requires/imports the
|
|
8
|
+
* target file and prints its tokens as JSON.
|
|
9
|
+
*
|
|
10
|
+
* Recognised export shapes (in priority order):
|
|
11
|
+
* 1. `module.exports.tokens = { … }` (CJS named)
|
|
12
|
+
* 2. `module.exports = { tokens: { … } }` (CJS object with `tokens`)
|
|
13
|
+
* 3. `module.exports = { … }` (CJS bag — direct map of name→value)
|
|
14
|
+
* 4. `export const tokens = { … }` (ESM named) — handled via dynamic import in harness
|
|
15
|
+
* 5. `export default { tokens: { … } }` (ESM default) — same
|
|
16
|
+
*
|
|
17
|
+
* Returns flat `{name: value}` map. Nested objects flattened with `.`
|
|
18
|
+
* separator (e.g. `{color: {primary: '#abc'}}` → `color.primary`).
|
|
19
|
+
*
|
|
20
|
+
* Values are stringified via `String(value)`; arrays JSON-encoded.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const { spawnSync } = require('node:child_process');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
|
|
29
|
+
const HARNESS_PATH = require('node:path').join(__dirname, '_js-harness.cjs');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Flatten a nested object into dotted keys.
|
|
33
|
+
*
|
|
34
|
+
* @param {unknown} val
|
|
35
|
+
* @param {string} prefix
|
|
36
|
+
* @param {Record<string, string>} out
|
|
37
|
+
*/
|
|
38
|
+
function flatten(val, prefix, out) {
|
|
39
|
+
if (val === null || val === undefined) return;
|
|
40
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
41
|
+
out[prefix] = String(val);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(val)) {
|
|
45
|
+
out[prefix] = JSON.stringify(val);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (typeof val === 'object') {
|
|
49
|
+
for (const k of Object.keys(val)) {
|
|
50
|
+
const next = prefix ? `${prefix}.${k}` : k;
|
|
51
|
+
flatten(val[k], next, out);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
out[prefix] = String(val);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read tokens from a JS/CJS/MJS module file.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} filePath
|
|
62
|
+
* @returns {{tokens: Record<string, string>, source: string, format: 'js-const', warnings: string[]}}
|
|
63
|
+
*/
|
|
64
|
+
function readJsConst(filePath) {
|
|
65
|
+
const abs = path.resolve(filePath);
|
|
66
|
+
if (!fs.existsSync(abs)) {
|
|
67
|
+
throw new Error(`js-const: file not found: ${abs}`);
|
|
68
|
+
}
|
|
69
|
+
if (!fs.existsSync(HARNESS_PATH)) {
|
|
70
|
+
throw new Error(`js-const: harness missing at ${HARNESS_PATH}`);
|
|
71
|
+
}
|
|
72
|
+
const r = spawnSync(process.execPath, [HARNESS_PATH, abs], {
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
timeout: 15_000,
|
|
75
|
+
});
|
|
76
|
+
/** @type {string[]} */
|
|
77
|
+
const warnings = [];
|
|
78
|
+
if (r.status !== 0) {
|
|
79
|
+
return {
|
|
80
|
+
tokens: {},
|
|
81
|
+
source: abs,
|
|
82
|
+
format: 'js-const',
|
|
83
|
+
warnings: [
|
|
84
|
+
`harness-exit-${r.status}: ${(r.stderr || '').slice(0, 400)}`,
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** @type {{tokens?: unknown, error?: string}} */
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(r.stdout);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return {
|
|
94
|
+
tokens: {},
|
|
95
|
+
source: abs,
|
|
96
|
+
format: 'js-const',
|
|
97
|
+
warnings: [`harness-output-parse-failed: ${err.message}`],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (parsed.error) warnings.push(parsed.error);
|
|
101
|
+
/** @type {Record<string, string>} */
|
|
102
|
+
const flat = {};
|
|
103
|
+
flatten(parsed.tokens, '', flat);
|
|
104
|
+
return { tokens: flat, source: abs, format: 'js-const', warnings };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { readJsConst, _flatten: flatten };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design-tokens/tailwind.cjs — extract tokens from a Tailwind config
|
|
3
|
+
* (Plan 23-08).
|
|
4
|
+
*
|
|
5
|
+
* Reuses the js-const subprocess harness to evaluate the config (which
|
|
6
|
+
* may be CJS or ESM, plain JS or .ts via type-strip), then walks
|
|
7
|
+
* `theme` + `theme.extend` into a flat token map keyed by
|
|
8
|
+
* `<scale>.<key>` (e.g. `colors.brand.500`, `spacing.4`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { spawnSync } = require('node:child_process');
|
|
16
|
+
|
|
17
|
+
const HARNESS_PATH = path.join(__dirname, '_js-harness.cjs');
|
|
18
|
+
|
|
19
|
+
function flatten(val, prefix, out) {
|
|
20
|
+
if (val === null || val === undefined) return;
|
|
21
|
+
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
|
|
22
|
+
out[prefix] = String(val);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(val)) {
|
|
26
|
+
out[prefix] = JSON.stringify(val);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (typeof val === 'object') {
|
|
30
|
+
for (const k of Object.keys(val)) {
|
|
31
|
+
const next = prefix ? `${prefix}.${k}` : k;
|
|
32
|
+
flatten(val[k], next, out);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read a Tailwind config. Prefers `theme.extend` over base `theme` for
|
|
39
|
+
* each scale (matching Tailwind merge semantics: extend extends).
|
|
40
|
+
*
|
|
41
|
+
* @param {string} filePath
|
|
42
|
+
* @returns {{tokens: Record<string, string>, source: string, format: 'tailwind', warnings: string[]}}
|
|
43
|
+
*/
|
|
44
|
+
function readTailwind(filePath) {
|
|
45
|
+
const abs = path.resolve(filePath);
|
|
46
|
+
if (!fs.existsSync(abs)) {
|
|
47
|
+
throw new Error(`tailwind: file not found: ${abs}`);
|
|
48
|
+
}
|
|
49
|
+
const r = spawnSync(process.execPath, [HARNESS_PATH, abs], {
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
timeout: 15_000,
|
|
52
|
+
});
|
|
53
|
+
/** @type {string[]} */
|
|
54
|
+
const warnings = [];
|
|
55
|
+
if (r.status !== 0) {
|
|
56
|
+
return {
|
|
57
|
+
tokens: {},
|
|
58
|
+
source: abs,
|
|
59
|
+
format: 'tailwind',
|
|
60
|
+
warnings: [`harness-exit-${r.status}: ${(r.stderr || '').slice(0, 400)}`],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(r.stdout);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return {
|
|
68
|
+
tokens: {},
|
|
69
|
+
source: abs,
|
|
70
|
+
format: 'tailwind',
|
|
71
|
+
warnings: [`harness-output-parse-failed: ${err.message}`],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (parsed.error) warnings.push(parsed.error);
|
|
75
|
+
/** @type {Record<string, string>} */
|
|
76
|
+
const out = {};
|
|
77
|
+
const config = parsed.tokens ?? {};
|
|
78
|
+
// The harness returns the module exports (or `tokens` field). Tailwind
|
|
79
|
+
// configs export an object with a `theme` field. Some configs export
|
|
80
|
+
// `{theme, plugins, …}` directly.
|
|
81
|
+
const theme = config.theme || config;
|
|
82
|
+
// Base theme.
|
|
83
|
+
if (theme && typeof theme === 'object') {
|
|
84
|
+
for (const scale of Object.keys(theme)) {
|
|
85
|
+
if (scale === 'extend') continue;
|
|
86
|
+
flatten(theme[scale], scale, out);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// theme.extend overrides per-scale.
|
|
90
|
+
if (theme && theme.extend && typeof theme.extend === 'object') {
|
|
91
|
+
for (const scale of Object.keys(theme.extend)) {
|
|
92
|
+
flatten(theme.extend[scale], scale, out);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { tokens: out, source: abs, format: 'tailwind', warnings };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { readTailwind };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain-primitives/anti-patterns.cjs — anti-pattern regex matcher
|
|
3
|
+
* (Plan 23-09).
|
|
4
|
+
*
|
|
5
|
+
* Same shape as nng.cjs. Loads rules from `reference/anti-patterns.md`
|
|
6
|
+
* yaml blocks. Caller may inject rules via opts.rules.
|
|
7
|
+
*
|
|
8
|
+
* Rule yaml shape:
|
|
9
|
+
* id: ban-01
|
|
10
|
+
* severity: P1
|
|
11
|
+
* grep: 'side-stripe-class'
|
|
12
|
+
* summary: 'Side-stripe borders are an AI-slop tell'
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { parseRulesFromMarkdown } = require('./nng.cjs');
|
|
20
|
+
|
|
21
|
+
let _cache = null;
|
|
22
|
+
|
|
23
|
+
function loadRules(cwd) {
|
|
24
|
+
if (_cache) return _cache;
|
|
25
|
+
const root = cwd ?? path.resolve(__dirname, '..', '..', '..');
|
|
26
|
+
const file = path.join(root, 'reference', 'anti-patterns.md');
|
|
27
|
+
if (!fs.existsSync(file)) {
|
|
28
|
+
_cache = [];
|
|
29
|
+
return _cache;
|
|
30
|
+
}
|
|
31
|
+
_cache = parseRulesFromMarkdown(fs.readFileSync(file, 'utf8'));
|
|
32
|
+
return _cache;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{file: string, content: string, type?: string, cwd?: string, rules?: object[]}} input
|
|
37
|
+
* @returns {Array<{rule_id: string, severity: string, summary: string, evidence?: string, line?: number, file: string}>}
|
|
38
|
+
*/
|
|
39
|
+
function check(input) {
|
|
40
|
+
if (!input || typeof input !== 'object') return [];
|
|
41
|
+
if (typeof input.content !== 'string' || typeof input.file !== 'string') return [];
|
|
42
|
+
const rules = Array.isArray(input.rules) ? input.rules : loadRules(input.cwd);
|
|
43
|
+
const hits = [];
|
|
44
|
+
const lines = input.content.split(/\r?\n/);
|
|
45
|
+
for (const rule of rules) {
|
|
46
|
+
for (let i = 0; i < lines.length; i++) {
|
|
47
|
+
const m = lines[i].match(rule.grep);
|
|
48
|
+
if (!m) continue;
|
|
49
|
+
hits.push({
|
|
50
|
+
rule_id: rule.id,
|
|
51
|
+
severity: rule.severity,
|
|
52
|
+
summary: rule.summary,
|
|
53
|
+
evidence: m[0].slice(0, 200),
|
|
54
|
+
line: i + 1,
|
|
55
|
+
file: input.file,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return hits;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _resetCache() {
|
|
63
|
+
_cache = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { check, _resetCache };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* domain-primitives/nng.cjs — NNG-style heuristic checker
|
|
3
|
+
* (Plan 23-09).
|
|
4
|
+
*
|
|
5
|
+
* Runs grep-style rules against a single source artifact. Rules are
|
|
6
|
+
* loaded from `reference/heuristics.md` as fenced yaml blocks of the form:
|
|
7
|
+
*
|
|
8
|
+
* ```yaml
|
|
9
|
+
* id: nng-01
|
|
10
|
+
* severity: P1
|
|
11
|
+
* grep: 'placeholder-as-label'
|
|
12
|
+
* summary: 'Inputs use placeholder text instead of an explicit label'
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* If the reference file has no parseable yaml blocks today (the current
|
|
16
|
+
* registry is prose), the checker simply treats the rule list as empty.
|
|
17
|
+
* Caller may supply `opts.rules` directly to bypass the file-load path,
|
|
18
|
+
* which is the test-friendly entry point.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const SEVERITIES = new Set(['P0', 'P1', 'P2', 'P3']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} HeuristicHit
|
|
30
|
+
* @property {string} rule_id
|
|
31
|
+
* @property {'P0'|'P1'|'P2'|'P3'} severity
|
|
32
|
+
* @property {string} summary
|
|
33
|
+
* @property {string} [evidence]
|
|
34
|
+
* @property {number} [line]
|
|
35
|
+
* @property {string} file
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} CompiledRule
|
|
40
|
+
* @property {string} id
|
|
41
|
+
* @property {'P0'|'P1'|'P2'|'P3'} severity
|
|
42
|
+
* @property {RegExp} grep
|
|
43
|
+
* @property {string} summary
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract every fenced ```yaml block from a markdown string and parse
|
|
48
|
+
* each as a flat key:value mapping (single-level, no nesting). Skips
|
|
49
|
+
* blocks that don't have an `id` and `grep` field.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} markdown
|
|
52
|
+
* @returns {CompiledRule[]}
|
|
53
|
+
*/
|
|
54
|
+
function parseRulesFromMarkdown(markdown) {
|
|
55
|
+
const rules = [];
|
|
56
|
+
const re = /```yaml\s*\n([\s\S]*?)\n```/g;
|
|
57
|
+
let m;
|
|
58
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
59
|
+
const body = m[1];
|
|
60
|
+
/** @type {Record<string, string>} */
|
|
61
|
+
const fields = {};
|
|
62
|
+
for (const line of body.split(/\r?\n/)) {
|
|
63
|
+
const kv = line.match(/^\s*([A-Za-z_][\w-]*)\s*:\s*(.*?)\s*$/);
|
|
64
|
+
if (!kv) continue;
|
|
65
|
+
let v = kv[2];
|
|
66
|
+
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
|
|
67
|
+
v = v.slice(1, -1);
|
|
68
|
+
}
|
|
69
|
+
fields[kv[1]] = v;
|
|
70
|
+
}
|
|
71
|
+
if (!fields.id || !fields.grep || !SEVERITIES.has(fields.severity)) continue;
|
|
72
|
+
let regex;
|
|
73
|
+
try {
|
|
74
|
+
regex = new RegExp(fields.grep);
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
rules.push({
|
|
79
|
+
id: fields.id,
|
|
80
|
+
severity: /** @type {'P0'|'P1'|'P2'|'P3'} */ (fields.severity),
|
|
81
|
+
grep: regex,
|
|
82
|
+
summary: fields.summary || fields.id,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return rules;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let _ruleCache = null;
|
|
89
|
+
|
|
90
|
+
function loadRules(cwd) {
|
|
91
|
+
if (_ruleCache) return _ruleCache;
|
|
92
|
+
const root = cwd ?? path.resolve(__dirname, '..', '..', '..');
|
|
93
|
+
const file = path.join(root, 'reference', 'heuristics.md');
|
|
94
|
+
if (!fs.existsSync(file)) {
|
|
95
|
+
_ruleCache = [];
|
|
96
|
+
return _ruleCache;
|
|
97
|
+
}
|
|
98
|
+
const md = fs.readFileSync(file, 'utf8');
|
|
99
|
+
_ruleCache = parseRulesFromMarkdown(md);
|
|
100
|
+
return _ruleCache;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{file: string, content: string, type?: string, cwd?: string, rules?: CompiledRule[]}} input
|
|
105
|
+
* @returns {HeuristicHit[]}
|
|
106
|
+
*/
|
|
107
|
+
function check(input) {
|
|
108
|
+
if (!input || typeof input !== 'object') return [];
|
|
109
|
+
if (typeof input.content !== 'string' || typeof input.file !== 'string') return [];
|
|
110
|
+
const rules = Array.isArray(input.rules) ? input.rules : loadRules(input.cwd);
|
|
111
|
+
/** @type {HeuristicHit[]} */
|
|
112
|
+
const hits = [];
|
|
113
|
+
const lines = input.content.split(/\r?\n/);
|
|
114
|
+
for (const rule of rules) {
|
|
115
|
+
for (let i = 0; i < lines.length; i++) {
|
|
116
|
+
const ln = lines[i];
|
|
117
|
+
const match = ln.match(rule.grep);
|
|
118
|
+
if (!match) continue;
|
|
119
|
+
hits.push({
|
|
120
|
+
rule_id: rule.id,
|
|
121
|
+
severity: rule.severity,
|
|
122
|
+
summary: rule.summary,
|
|
123
|
+
evidence: match[0].slice(0, 200),
|
|
124
|
+
line: i + 1,
|
|
125
|
+
file: input.file,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return hits;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _resetCache() {
|
|
133
|
+
_ruleCache = null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { check, parseRulesFromMarkdown, _resetCache };
|