@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,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 };