@cookiecrumbs-eu/mcp 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +134 -0
  2. package/README.md +186 -0
  3. package/dist/cli/src/api.js +192 -0
  4. package/dist/cli/src/auth.js +106 -0
  5. package/dist/cli/src/commands/_shared.js +78 -0
  6. package/dist/cli/src/commands/alerts.js +85 -0
  7. package/dist/cli/src/commands/auth.js +92 -0
  8. package/dist/cli/src/commands/declaration.js +45 -0
  9. package/dist/cli/src/commands/diff.js +26 -0
  10. package/dist/cli/src/commands/domains.js +44 -0
  11. package/dist/cli/src/commands/export.js +136 -0
  12. package/dist/cli/src/commands/init.js +134 -0
  13. package/dist/cli/src/commands/install.js +77 -0
  14. package/dist/cli/src/commands/issues.js +61 -0
  15. package/dist/cli/src/commands/link.js +41 -0
  16. package/dist/cli/src/commands/logs.js +98 -0
  17. package/dist/cli/src/commands/open.js +45 -0
  18. package/dist/cli/src/commands/pull.js +89 -0
  19. package/dist/cli/src/commands/push.js +110 -0
  20. package/dist/cli/src/commands/scan.js +94 -0
  21. package/dist/cli/src/commands/schedule.js +97 -0
  22. package/dist/cli/src/commands/services.js +143 -0
  23. package/dist/cli/src/commands/sites.js +111 -0
  24. package/dist/cli/src/commands/status.js +90 -0
  25. package/dist/cli/src/commands/templates.js +133 -0
  26. package/dist/cli/src/commands/tokens.js +50 -0
  27. package/dist/cli/src/commands/usage.js +41 -0
  28. package/dist/cli/src/commands/versions.js +95 -0
  29. package/dist/cli/src/commands/webhooks.js +164 -0
  30. package/dist/cli/src/configpkg.js +10 -0
  31. package/dist/cli/src/diff.js +63 -0
  32. package/dist/cli/src/errors.js +20 -0
  33. package/dist/cli/src/frameworks.js +141 -0
  34. package/dist/cli/src/index.js +100 -0
  35. package/dist/cli/src/jobs.js +59 -0
  36. package/dist/cli/src/merge.js +38 -0
  37. package/dist/cli/src/output.js +112 -0
  38. package/dist/cli/src/project.js +269 -0
  39. package/dist/cli/src/util.js +122 -0
  40. package/dist/config/rules_reference.json +569 -0
  41. package/dist/config/src/canon.js +36 -0
  42. package/dist/config/src/declaration.js +38 -0
  43. package/dist/config/src/defaults.js +804 -0
  44. package/dist/config/src/export.js +130 -0
  45. package/dist/config/src/index.js +16 -0
  46. package/dist/config/src/lint.js +139 -0
  47. package/dist/config/src/regimes.js +62 -0
  48. package/dist/config/src/rules.js +90 -0
  49. package/dist/config/src/schema.js +323 -0
  50. package/dist/config/src/theme.js +147 -0
  51. package/dist/config/src/verify.js +51 -0
  52. package/dist/config/src/webhooks.js +309 -0
  53. package/dist/mcp/src/auth.js +40 -0
  54. package/dist/mcp/src/client.js +44 -0
  55. package/dist/mcp/src/diff.js +134 -0
  56. package/dist/mcp/src/index.js +25 -0
  57. package/dist/mcp/src/matrix.js +106 -0
  58. package/dist/mcp/src/server.js +171 -0
  59. package/dist/mcp/src/shared.js +147 -0
  60. package/dist/mcp/src/tools-config.js +943 -0
  61. package/dist/mcp/src/tools.js +650 -0
  62. package/package.json +66 -0
@@ -0,0 +1,130 @@
1
+ /** Every key of an export record (schema_registry `export_record` v1), always present, sorted. */
2
+ export const EXPORT_RECORD_KEYS = [
3
+ 'ac_string',
4
+ 'categories',
5
+ 'client_nonce',
6
+ 'client_ts',
7
+ 'collection',
8
+ 'country',
9
+ 'environment',
10
+ 'event_type',
11
+ 'gcm_state',
12
+ 'id',
13
+ 'language',
14
+ 'occurred_at',
15
+ 'prev_hash',
16
+ 'record_hash',
17
+ 'redacted',
18
+ 'redaction_prev_hash',
19
+ 'regime',
20
+ 'regime_rules_version',
21
+ 'sdk_version',
22
+ 'services',
23
+ 'site_id',
24
+ 'subject_id',
25
+ 'tc_string',
26
+ 'texts_hash',
27
+ 'ua_class',
28
+ 'version_id',
29
+ 'version_number',
30
+ ];
31
+ /** Column order of `consent_csv` (schema_registry `export_csv` v1). */
32
+ export const CSV_COLUMNS = [
33
+ 'occurred_at',
34
+ 'id',
35
+ 'event_type',
36
+ 'subject_id',
37
+ 'version_number',
38
+ 'version_id',
39
+ 'texts_hash',
40
+ 'language',
41
+ 'country',
42
+ 'regime',
43
+ 'regime_rules_version',
44
+ 'categories',
45
+ 'services',
46
+ 'gcm_state',
47
+ 'collection',
48
+ 'tc_string',
49
+ 'ac_string',
50
+ 'client_ts',
51
+ 'sdk_version',
52
+ 'client_nonce',
53
+ 'prev_hash',
54
+ 'record_hash',
55
+ 'redacted',
56
+ 'redaction_prev_hash',
57
+ ];
58
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
59
+ /**
60
+ * JSON with object keys sorted bytewise at every level and no whitespace. Unlike canonJson this
61
+ * accepts numbers (export records carry ints) and drops `undefined` members like JSON.stringify.
62
+ */
63
+ export function stableStringify(value) {
64
+ if (value === null || value === undefined)
65
+ return 'null';
66
+ if (typeof value === 'string' || typeof value === 'boolean')
67
+ return JSON.stringify(value);
68
+ if (typeof value === 'number')
69
+ return Number.isFinite(value) ? JSON.stringify(value) : 'null';
70
+ if (typeof value === 'bigint')
71
+ return value.toString();
72
+ if (Array.isArray(value))
73
+ return '[' + value.map(stableStringify).join(',') + ']';
74
+ if (typeof value === 'object') {
75
+ const obj = value;
76
+ const parts = [];
77
+ for (const k of Object.keys(obj).sort(byCodeUnit)) {
78
+ if (obj[k] === undefined)
79
+ continue;
80
+ parts.push(JSON.stringify(k) + ':' + stableStringify(obj[k]));
81
+ }
82
+ return '{' + parts.join(',') + '}';
83
+ }
84
+ throw new Error('stableStringify: unsupported value ' + typeof value);
85
+ }
86
+ /** One `records.jsonl` line (without the trailing `\n`): every export key present, null when absent. */
87
+ export function recordLine(record) {
88
+ const full = {};
89
+ for (const k of EXPORT_RECORD_KEYS)
90
+ full[k] = record[k] === undefined ? null : record[k];
91
+ return stableStringify(full);
92
+ }
93
+ /** Parses `records.jsonl` (blank lines ignored); throws with the 1-based line number on bad JSON. */
94
+ export function parseRecordsJsonl(text) {
95
+ const out = [];
96
+ const lines = text.split('\n');
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const line = lines[i].replace(/\r$/, '');
99
+ if (!line.trim())
100
+ continue;
101
+ try {
102
+ out.push(JSON.parse(line));
103
+ }
104
+ catch (e) {
105
+ throw new Error(`records.jsonl line ${i + 1}: ${e.message}`);
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+ /** RFC 4180: quote when the value contains `,`, `"`, `\n` (or `\r`); double embedded quotes. */
111
+ export function csvEscape(value) {
112
+ return /[",\n\r]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
113
+ }
114
+ function csvCell(v) {
115
+ if (v === null || v === undefined)
116
+ return '';
117
+ if (typeof v === 'boolean')
118
+ return v ? 'true' : 'false';
119
+ if (typeof v === 'string')
120
+ return csvEscape(v);
121
+ if (typeof v === 'number' || typeof v === 'bigint')
122
+ return String(v);
123
+ return csvEscape(stableStringify(v));
124
+ }
125
+ /** One CSV row (without the trailing `\n`) in CSV_COLUMNS order; JSON columns use stableStringify. */
126
+ export function toCsvRow(record) {
127
+ return CSV_COLUMNS.map((c) => csvCell(record[c])).join(',');
128
+ }
129
+ /** The CSV header line (without the trailing `\n`). */
130
+ export const CSV_HEADER = CSV_COLUMNS.join(',');
@@ -0,0 +1,16 @@
1
+ export * from "./schema.js";
2
+ export * from "./defaults.js";
3
+ export * from "./lint.js";
4
+ export * from "./theme.js";
5
+ export * from "./regimes.js";
6
+ export * from "./canon.js";
7
+ export * from "./verify.js";
8
+ export * from "./export.js";
9
+ export * from "./declaration.js";
10
+ export * from "./webhooks.js";
11
+ export * from "./rules.js";
12
+ import { BannerConfigSchema } from "./schema.js";
13
+ /** Identity helper for cookiecrumbs.config.ts files: validates and returns the config. */
14
+ export function defineConfig(config) {
15
+ return BannerConfigSchema.parse(config);
16
+ }
@@ -0,0 +1,139 @@
1
+ import { BannerConfigSchema, TEXT_KEYS } from "./schema.js";
2
+ import { contrast } from "./theme.js";
3
+ import { TRANSLATIONS } from "./defaults.js";
4
+ export { contrast };
5
+ /** Regimes whose model is opt-in (consent before any non-essential storage). */
6
+ const OPT_IN_REGIMES = ['eu_optin', 'uk_pecr', 'ch_fadp', 'br_lgpd', 'ca_qc'];
7
+ // --- WCAG contrast -------------------------------------------------
8
+ function channel(c) {
9
+ const s = c / 255;
10
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
11
+ }
12
+ export function luminance(hex) {
13
+ const n = parseInt(hex.slice(1), 16);
14
+ return 0.2126 * channel((n >> 16) & 255) + 0.7152 * channel((n >> 8) & 255) + 0.0722 * channel(n & 255);
15
+ }
16
+ /**
17
+ * Legal + schema lint. Errors block publishing (the SQL private.lint_config applies the same
18
+ * codes and messages); warnings and infos are advisory.
19
+ */
20
+ export function lintConfig(input, context = {}) {
21
+ const issues = [];
22
+ const parsed = BannerConfigSchema.safeParse(input);
23
+ if (!parsed.success) {
24
+ for (const e of parsed.error.issues) {
25
+ issues.push({ code: 'schema', severity: 'error', path: e.path.join('.'), message: e.message });
26
+ }
27
+ return { ok: false, issues };
28
+ }
29
+ const c = parsed.data;
30
+ const push = (code, severity, path, message) => issues.push({ code, severity, path, message });
31
+ const regimes = c.regions.regimes;
32
+ const enabled = (r) => !!regimes[r]?.enabled;
33
+ const necessary = c.categories.find((x) => x.key === 'necessary');
34
+ if (!necessary)
35
+ push('necessary_missing', 'error', 'categories', 'A locked "necessary" category is required.');
36
+ else if (!necessary.locked || !necessary.default)
37
+ push('necessary_not_locked', 'error', 'categories.necessary', 'The necessary category must be locked and on.');
38
+ const keys = new Set();
39
+ c.categories.forEach((cat, i) => {
40
+ if (keys.has(cat.key))
41
+ push('duplicate_category', 'error', `categories.${i}.key`, `Duplicate category "${cat.key}".`);
42
+ keys.add(cat.key);
43
+ if (cat.key !== 'necessary' && cat.locked)
44
+ push('locked_non_necessary', 'error', `categories.${i}.locked`, 'Only the necessary category may be locked.');
45
+ });
46
+ // Any enabled opt-in regime forbids pre-ticked non-essential categories (phase 1 checked eu_optin only).
47
+ if (OPT_IN_REGIMES.some(enabled)) {
48
+ c.categories.forEach((cat, i) => {
49
+ if (cat.key !== 'necessary' && cat.default)
50
+ push('default_on_non_necessary', 'error', `categories.${i}.default`, `"${cat.key}" cannot be on by default under an opt-in regime (GDPR Art. 4(11), EDPB 05/2020).`);
51
+ });
52
+ }
53
+ if (!c.languages.includes(c.default_lang))
54
+ push('no_default_lang', 'error', 'default_lang', 'The default language must be one of the enabled languages.');
55
+ const usOptout = enabled('us_optout');
56
+ const impliedDismiss = !!c.behaviour.implied_dismiss;
57
+ for (const lang of c.languages) {
58
+ const t = c.texts[lang];
59
+ if (!t) {
60
+ push('missing_text', 'error', `texts.${lang}`, `No texts for language "${lang}".`);
61
+ continue;
62
+ }
63
+ for (const k of TEXT_KEYS) {
64
+ if (!t[k] || !t[k].trim())
65
+ push('missing_text', 'error', `texts.${lang}.${k}`, `"${k}" is empty for "${lang}".`);
66
+ }
67
+ if (t.body && t.body.trim().length < 40)
68
+ push('short_body', 'warning', `texts.${lang}.body`, 'The first-layer text is very short; say what cookies are used for.');
69
+ if (usOptout && !(t.do_not_sell || '').trim())
70
+ push('us_link_text_missing', 'error', `texts.${lang}.do_not_sell`, `"do_not_sell" is empty for "${lang}"; a "Do Not Sell or Share" link is required under CCPA/CPRA (Cal. Civ. Code § 1798.135).`);
71
+ if (impliedDismiss && !(t.close_refuses || '').trim())
72
+ push('implied_dismiss_text_missing', 'error', `texts.${lang}.close_refuses`, `"close_refuses" is empty for "${lang}"; visitors must be told that closing counts as refusing (GDPR Art. 7(2), EDPB 05/2020).`);
73
+ const ct = c.category_texts[lang] ?? {};
74
+ for (const cat of c.categories) {
75
+ if (!ct[cat.key])
76
+ push('missing_text', 'error', `category_texts.${lang}.${cat.key}`, `No name/description for "${cat.key}" in "${lang}".`);
77
+ }
78
+ }
79
+ for (const [host, cat] of Object.entries(c.block)) {
80
+ if (!keys.has(cat))
81
+ push('block_unknown_category', 'error', `block.${host}`, `Category "${cat}" does not exist.`);
82
+ }
83
+ // Regions: the fallback must be usable; overrides must point at an enabled regime.
84
+ if (!enabled(c.regions.fallback))
85
+ push('fallback_disabled', 'error', 'regions.fallback', `Fallback regime "${c.regions.fallback}" is not enabled; enable it or choose an enabled regime.`);
86
+ for (const [cc, r] of Object.entries(c.regions.overrides ?? {})) {
87
+ if (!enabled(r))
88
+ push('regime_unknown_override', 'error', `regions.overrides.${cc}`, `Override for "${cc}" points to regime "${r}", which is not enabled.`);
89
+ }
90
+ const ct = contrast(c.theme.text, c.theme.background);
91
+ if (ct < 4.5)
92
+ push('contrast_text', 'error', 'theme.text', `Text on background is ${ct.toFixed(2)}:1; 4.5:1 is required (WCAG 1.4.3).`);
93
+ const cb = contrast(c.theme.button_text, c.theme.button);
94
+ if (cb < 4.5)
95
+ push('contrast_button', 'error', 'theme.button_text', `Button text on button is ${cb.toFixed(2)}:1; 4.5:1 is required.`);
96
+ const cbb = contrast(c.theme.button, c.theme.background);
97
+ if (cbb < 3)
98
+ push('contrast_button_bg', 'warning', 'theme.button', `Buttons are hard to see on the background (${cbb.toFixed(2)}:1).`);
99
+ // ---- the wording -----------------------------------------------------------
100
+ // The built-in body names what is processed, what for, that data may reach countries without an
101
+ // adequate level of protection, and that consent is voluntary and revocable. A much shorter text
102
+ // probably says none of that. A warning, not a blocker: a short custom text can be perfectly legal
103
+ // and refusing to publish it would be us overruling the operator's own lawyer.
104
+ for (const lang of c.languages) {
105
+ const body = (c.texts[lang]?.body ?? '').trim();
106
+ const built = TRANSLATIONS[lang]?.texts.body;
107
+ if (built && body.length < built.length * 0.45) {
108
+ push('body_below_standard', 'warning', `texts.${lang}.body`, `This text is much shorter than the wording CookieCrumbs ships, which names what is processed, what it is used for, that data may reach countries without an adequate level of protection, and that consent is voluntary. Use the built-in wording to restore it.`);
109
+ }
110
+ }
111
+ // ---- the site's own pages -------------------------------------------------
112
+ // An imprint is a legal requirement wherever this ships (in Germany and Austria it is explicit), and
113
+ // a consent notice is often the only chrome a visitor sees before deciding. It is required.
114
+ const url = (v) => !!v && /^https?:\/\/\S+$/i.test(v.trim());
115
+ if (!url(c.links?.imprint)) {
116
+ push('imprint_missing', 'error', 'links.imprint', 'An imprint address is required. Add it under Legal; it is shown in the footer of every banner.');
117
+ }
118
+ // A data protection notice is as much a requirement for an EU banner as the imprint: the consent
119
+ // text refers to processing the notice must describe (GDPR Arts. 12-13).
120
+ if (!url(c.links?.privacy)) {
121
+ push('privacy_link_missing', 'error', 'links.privacy', 'A data protection notice is required. Add its address under Legal; it is linked in the footer of every banner next to the imprint.');
122
+ }
123
+ if (!['floating', 'link'].includes(c.behaviour.reopen_control))
124
+ push('reopen_disabled', 'error', 'behaviour.reopen_control', 'Visitors must be able to reopen the banner (GDPR Art. 7(3)).');
125
+ // Quebec Law 25 s. 9.1: the settings control is presented as confidentiality settings.
126
+ if (enabled('ca_qc') && regimes.ca_qc?.settings_label !== 'default') {
127
+ for (const lang of c.languages) {
128
+ const t = c.texts[lang];
129
+ if (t && !(t.confidentiality_settings || '').trim())
130
+ push('qc_settings_text_missing', 'warning', `texts.${lang}.confidentiality_settings`, `"confidentiality_settings" is empty for "${lang}"; under Quebec Law 25 (s. 9.1) the settings control is presented as confidentiality settings.`);
131
+ }
132
+ }
133
+ // Advanced Consent Mode without scan evidence (public.gcm_verification): informational, never blocking.
134
+ if (c.consent_mode.enabled && c.consent_mode.mode === 'advanced' && context.gcm_verified === false)
135
+ push('gcm_advanced_unverified', 'info', 'consent_mode.mode', 'Advanced Consent Mode is on but the last completed scan observed no gtag consent default call on this site; run a scan after installing the tag.');
136
+ if (c.layout === 'headless')
137
+ push('headless_layout', 'info', 'layout', 'Headless layout renders no banner; your own UI must offer Accept all, Reject all and settings with equal prominence.');
138
+ return { ok: !issues.some((i) => i.severity === 'error'), issues };
139
+ }
@@ -0,0 +1,62 @@
1
+ /** Human labels used by the dashboard, CLI and tests. */
2
+ export const REGIME_LABELS = {
3
+ eu_optin: 'EU / EEA opt-in (GDPR + ePrivacy)',
4
+ uk_pecr: 'United Kingdom (UK GDPR + PECR)',
5
+ us_optout: 'United States opt-out (CCPA/CPRA and state laws)',
6
+ ch_fadp: 'Switzerland (FADP)',
7
+ br_lgpd: 'Brazil (LGPD)',
8
+ ca_qc: 'Quebec, Canada (Law 25)',
9
+ };
10
+ /** The 27 EU member states plus the three EEA/EFTA countries (IS, LI, NO). */
11
+ export const EU_EEA_COUNTRIES = [
12
+ 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
13
+ 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
14
+ 'IS', 'LI', 'NO',
15
+ ];
16
+ /**
17
+ * region_rule_sets v3 `countries` map. `CA` resolves to `ca_qc` on purpose: the IP tables the platform
18
+ * uses (`private.ip_country`, geoip-lite country mode) have no province granularity, Law 25 binds anyone
19
+ * doing business in Quebec, and its opt-in model also satisfies PIPEDA. Sites that do not want Quebec
20
+ * rules for the rest of Canada set `regions.overrides.CA`.
21
+ */
22
+ export const COUNTRY_REGIMES = {
23
+ ...Object.fromEntries(EU_EEA_COUNTRIES.map((c) => [c, 'eu_optin'])),
24
+ GB: 'uk_pecr',
25
+ US: 'us_optout',
26
+ CH: 'ch_fadp',
27
+ BR: 'br_lgpd',
28
+ CA: 'ca_qc',
29
+ };
30
+ /** region_rule_sets v3 `subdivisions` map (ISO 3166-2), consulted before `countries`. */
31
+ export const SUBDIVISION_REGIMES = {
32
+ 'CA-QC': 'ca_qc',
33
+ };
34
+ /** Regime definitions of region_rule_sets v2 (`regimes`). */
35
+ export const REGIME_RULES = {
36
+ eu_optin: { model: 'opt_in', expiry_months_max: 13 },
37
+ uk_pecr: { model: 'opt_in', expiry_months_max: 13 },
38
+ us_optout: { model: 'opt_out', expiry_months_max: 12 },
39
+ ch_fadp: { model: 'opt_in', expiry_months_max: 13 },
40
+ br_lgpd: { model: 'opt_in', expiry_months_max: 13 },
41
+ ca_qc: { model: 'opt_in', expiry_months_max: 13 },
42
+ };
43
+ export const REGION_RULES_VERSION = 3;
44
+ export const REGIME_FALLBACK = 'eu_optin';
45
+ /**
46
+ * Mirrors public.resolve_regime(code) on region_rule_sets v3: an ISO 3166-2 subdivision (`CA-QC`) is
47
+ * looked up first, then the ISO 3166-1 alpha-2 country, then the rule set's fallback (`eu_optin`) for
48
+ * null / unknown input. Case-insensitive.
49
+ */
50
+ export function defaultRegimeForCountry(country) {
51
+ if (!country)
52
+ return REGIME_FALLBACK;
53
+ const code = country.trim().toUpperCase();
54
+ return SUBDIVISION_REGIMES[code] ?? COUNTRY_REGIMES[code.split('-')[0]] ?? REGIME_FALLBACK;
55
+ }
56
+ /** Maximum consent lifetime the runtime applies for a regime (months): 12 for us_optout, else 13. */
57
+ export function expiryCapMonths(regime) {
58
+ return REGIME_RULES[regime]?.expiry_months_max ?? 13;
59
+ }
60
+ export function isRegime(x) {
61
+ return typeof x === 'string' && x in REGIME_RULES;
62
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The shared rules reference: one citation knowledge base for the MCP tools
3
+ * (`rules_reference`, `check_first_layer`, `explain_classification`), the dashboard Issue
4
+ * detail page and the CLI.
5
+ *
6
+ * Rules of the file (contracts.md, phase 6):
7
+ * - every entry cites a primary source with a URL and a publication date;
8
+ * - `quote` is either the *verified* wording of that source or `null` — never a paraphrase
9
+ * dressed up as a quotation. Entries without a verified quote carry a `summary` in our
10
+ * own words;
11
+ * - nothing here is legal advice. Callers must keep showing the
12
+ * "This is not a legal assessment" line.
13
+ */
14
+ import reference from '../rules_reference.json' with { type: 'json' };
15
+ const REFERENCE = reference;
16
+ /** Compliance issue codes — mirrors the `compliance_issues_code_check` constraint. */
17
+ export const ISSUE_KINDS = [
18
+ 'preconsent_tracker',
19
+ 'unclassified_tracker',
20
+ 'install_unverified',
21
+ 'domain_unverified',
22
+ 'no_screenshots',
23
+ 'consent_mode_mismatch',
24
+ 'reject_not_layer1',
25
+ 'version_missing_texts',
26
+ 'declaration_stale',
27
+ 'a11y_violation',
28
+ ];
29
+ /** Every code `lintConfig()` can emit. The test asserts this list against src/lint.ts. */
30
+ export const LINT_CODES = [
31
+ 'block_unknown_category',
32
+ 'body_below_standard',
33
+ 'contrast_button',
34
+ 'contrast_button_bg',
35
+ 'contrast_text',
36
+ 'default_on_non_necessary',
37
+ 'duplicate_category',
38
+ 'fallback_disabled',
39
+ 'gcm_advanced_unverified',
40
+ 'headless_layout',
41
+ 'implied_dismiss_text_missing',
42
+ 'imprint_missing',
43
+ 'locked_non_necessary',
44
+ 'missing_text',
45
+ 'necessary_missing',
46
+ 'necessary_not_locked',
47
+ 'no_default_lang',
48
+ 'privacy_link_missing',
49
+ 'qc_settings_text_missing',
50
+ 'regime_unknown_override',
51
+ 'reopen_disabled',
52
+ 'schema',
53
+ 'short_body',
54
+ 'us_link_text_missing',
55
+ ];
56
+ /** The full reference, frozen. */
57
+ export const RULES_REFERENCE = REFERENCE;
58
+ export const RULES = REFERENCE.rules;
59
+ export const RULE_TOPICS = REFERENCE.topics;
60
+ /** The line every surface must keep next to a citation. */
61
+ export const NOT_LEGAL_ADVICE = 'This is not a legal assessment.';
62
+ const byId = new Map(REFERENCE.rules.map((r) => [r.id, r]));
63
+ /** One rule by its stable id, or null. */
64
+ export function ruleById(id) {
65
+ return byId.get(id) ?? null;
66
+ }
67
+ /** Every rule filed under a topic, in file order. Unknown topics return []. */
68
+ export function rulesFor(topic) {
69
+ return REFERENCE.rules.filter((r) => r.topic === topic);
70
+ }
71
+ /**
72
+ * Every rule that explains an issue kind (`compliance_issues.code`) or a lint code.
73
+ * Both vocabularies live in `applies_to`, so one accessor serves the Issue detail page and
74
+ * the legal lint alike.
75
+ */
76
+ export function citationsFor(issueKind) {
77
+ return REFERENCE.rules.filter((r) => r.applies_to.includes(issueKind));
78
+ }
79
+ /** Free-text search over source, summary, quote and paragraph — used by the MCP `rules_reference` tool. */
80
+ export function searchRules(query) {
81
+ const q = query.trim().toLowerCase();
82
+ if (!q)
83
+ return [...REFERENCE.rules];
84
+ return REFERENCE.rules.filter((r) => [r.id, r.topic, r.source, r.summary, r.quote ?? '', r.paragraph ?? '', ...r.applies_to].join(' ').toLowerCase().includes(q));
85
+ }
86
+ /** A one-line rendering, e.g. `EDPB … (para. 8) — https://…`. Never invents a quote. */
87
+ export function formatCitation(rule) {
88
+ const where = rule.paragraph ? ` (${rule.paragraph})` : '';
89
+ return `${rule.source}${where} — ${rule.source_url}`;
90
+ }