@domandigital/craft 0.9.0 → 0.11.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/dist/index.js CHANGED
@@ -1782,10 +1782,14 @@ var COPY_TELLS = [
1782
1782
  surface: "copy",
1783
1783
  why: "Three fragments in a row ('Fast. Friendly. Local.' or 'No fuss. No jargon. Just results.') is the second wave's favourite rhythm.",
1784
1784
  fix: "Write one sentence that says which of the three is true, and how you know.",
1785
- detect: phrases([/(?:\b[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!]\s+){2}[A-Z][\w'’-]*(?:\s+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
1785
+ // The fragments share one paragraph: "You don't. Again.", a blank line, then "It's frustrating." is two beats, not three.
1786
+ detect: phrases([/(?:\b[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?:[ \t]+|[ \t]*\n(?![ \t]*\n)[ \t]*)){2}[A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[.!](?=\s|$)/], quoted),
1786
1787
  fixtures: {
1787
- flag: [f("content/home.md", "No fuss. No jargon. Just results.")],
1788
- pass: [f("content/home.md", "We fix boilers the same day, and we tell you the price before we start.")]
1788
+ flag: [f("content/home.md", "No fuss. No jargon. Just results."), f("content/home.md", "No fuss.\nNo jargon.\nJust results.")],
1789
+ pass: [
1790
+ f("content/home.md", "We fix boilers the same day, and we tell you the price before we start."),
1791
+ f("content/guide.md", "Your competitor appears in the top three. You don't. Again.\n\nIt's frustrating.")
1792
+ ]
1789
1793
  }
1790
1794
  },
1791
1795
  {
@@ -1804,6 +1808,14 @@ var COPY_TELLS = [
1804
1808
  }
1805
1809
  ];
1806
1810
  var ING_TAIL = /,\s+(?:ensuring|highlighting|underscoring|showcasing|reflecting|demonstrating|emphasi[sz]ing|fostering|cementing|solidifying|reinforcing|symboli[sz]ing|signalling|signaling)\b[^.!?\n]{0,80}/i;
1811
+ var PROMPT_CONTEXT = [
1812
+ /\b(?:the|this|that)\s+(?:attached|uploaded|provided)\s+(?:(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\s+)?(?:research|research file|research pack|source file|sources)\b/i,
1813
+ /\b(?:attached|uploaded)\s+(?:Perplexity|ChatGPT|Claude|Gemini|Copilot)\b/i,
1814
+ /\bthe\s+(?:attached|uploaded|provided)\s+brief\s+(?:asks|says|wants|requests|suggests|mentions)\b/i
1815
+ ];
1816
+ function gerundList(block, _match, index) {
1817
+ return /(?:^|[(,;:]\s*)\w+ing\b[^,.;()!?]{0,40}$/.test(block.text.slice(Math.max(0, index - 60), index));
1818
+ }
1807
1819
  var VAGUE_ATTRIBUTION = [
1808
1820
  /\b(?:studies|research|experts|scientists|surveys|statistics|the data)\s+(?:show|shows|suggest|suggests|agree|say|says|confirm|confirms|prove|proves|indicate|indicates)\b/i,
1809
1821
  /\b(?:many|most)\s+(?:experts|people|customers|professionals|homeowners)\s+(?:agree|say|believe)\b/i,
@@ -1889,8 +1901,10 @@ function inlineLabelHits(ctx) {
1889
1901
  run = [];
1890
1902
  };
1891
1903
  for (const line of lines) {
1892
- const m = /^\s*(?:[-*+]|\d+\.)\s+\*\*[^*]{1,40}:?\*\*:?\s+(\S.*)$/.exec(line);
1893
- if (m && m[1].trim().split(/\s+/).length <= 6) run.push(offset);
1904
+ const m = /^\s*[-*+]\s+\*\*([^*]{1,40}?):?\*\*:?\s+(\S.*)$/.exec(line);
1905
+ const data = m && /[\d£$€%`]|https?:|^\*\*/.test(m[2]);
1906
+ const named = m && /\S[A-Z]|[./]/.test(m[1]);
1907
+ if (m && !data && !named && m[2].trim().split(/\s+/).length <= 6) run.push(offset);
1894
1908
  else flush();
1895
1909
  offset += line.length + 1;
1896
1910
  }
@@ -1928,6 +1942,29 @@ var RESEARCH_TELLS = [
1928
1942
  ]
1929
1943
  }
1930
1944
  },
1945
+ {
1946
+ id: "prompt-context",
1947
+ name: "Prompt context",
1948
+ generation: 1,
1949
+ severity: "warn",
1950
+ surface: "copy",
1951
+ why: "'This article uses the attached Perplexity research file as the primary source', 'the attached research is right to say': the model talking about what it was given. A reader of a web page has no attachment. Found 38 times across five live DD articles in 2026-09, none caught by any other tell.",
1952
+ fix: "Delete the reference to the research or brief. Where it carried a claim, name the real source or cut the claim.",
1953
+ detect: phrases(PROMPT_CONTEXT, quoted),
1954
+ fixtures: {
1955
+ flag: [
1956
+ f("content/guide.md", "This article uses the attached Perplexity research file as the primary source."),
1957
+ f("content/guide.md", "The attached research is right to say these platforms suit early-stage businesses."),
1958
+ f("content/guide.md", "That is exactly how the uploaded research frames the category."),
1959
+ f("content/guide.md", "The attached brief asks for the point that most tradespeople miss.")
1960
+ ],
1961
+ pass: [
1962
+ f("content/guide.md", "We research every quote before we send it."),
1963
+ f("app/email.tsx", `export const Invoice = () => <p>Your invoice is attached.</p>;`),
1964
+ f("content/guide.md", "BrightLocal's research shows that reviews drive 20% of local ranking.")
1965
+ ]
1966
+ }
1967
+ },
1931
1968
  {
1932
1969
  id: "placeholder",
1933
1970
  name: "Unfilled placeholder",
@@ -1980,7 +2017,11 @@ var RESEARCH_TELLS = [
1980
2017
  pass: [
1981
2018
  f("content/home.md", "- Pages load in under a second\n- Card details never touch our server\n- Support by phone until 8pm\n"),
1982
2019
  f("content/home.md", "- **Speed:** Faster pages\n- **Security:** Safer data\n"),
1983
- f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n")
2020
+ f("content/guide.md", "- **GDC**: The statutory regulator for all dental professionals.\n- **GCC**: The statutory regulator for chiropractors in the UK.\n- **CQC**: The independent regulator of health and social care in England.\n"),
2021
+ f("content/guide.md", "- **Per page**: \xA3100-\xA3200 per page\n- **Hourly**: \xA350-\xA375/hour\n- **Day rate**: \xA3350-\xA3500\n"),
2022
+ f("content/guide.md", "- **Law Society Find a Solicitor:** For solicitors\n- **AccountingWEB / ICAEW directories:** For accountants\n- **NHS.uk:** For NHS services\n"),
2023
+ f("content/guide.md", "1. **Visit PageSpeed Insights** and paste the address\n2. **Enter their URL** and run it\n3. **Note their scores** for later\n"),
2024
+ f("content/guide.md", "- **Structural problems:** **REBUILD.**\n- **Messaging issues:** **REFRESH.**\n- **Neither:** **LEAVE IT.**\n")
1984
2025
  ]
1985
2026
  }
1986
2027
  },
@@ -1992,7 +2033,7 @@ var RESEARCH_TELLS = [
1992
2033
  surface: "copy",
1993
2034
  why: "'..., ensuring peace of mind', '..., highlighting our commitment': a participle tacked on the end that claims significance and names no mechanism. One of the most common shapes in the Wikipedia guide to AI writing.",
1994
2035
  fix: "Cut the tail, or replace it with the mechanism: 'so you can drop the car off before work'.",
1995
- detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, ""))),
2036
+ detect: phrases([ING_TAIL], (m) => quoted(m.replace(/^,\s*/, "")), void 0, gerundList),
1996
2037
  fixtures: {
1997
2038
  flag: [
1998
2039
  f("content/home.md", "Every engineer is Gas Safe registered, ensuring complete peace of mind."),
@@ -2000,7 +2041,8 @@ var RESEARCH_TELLS = [
2000
2041
  ],
2001
2042
  pass: [
2002
2043
  f("content/home.md", "We open at 7am, so you can drop the car off before work."),
2003
- f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers.")
2044
+ f("content/home.md", "Engineers arrive by 9, carrying the parts for most boilers."),
2045
+ f("content/guide.md", "Many items (claiming your GBP, ensuring NAP consistency, writing title tags) are straightforward.")
2004
2046
  ]
2005
2047
  }
2006
2048
  },
@@ -2250,6 +2292,7 @@ function repeatHits(ctx) {
2250
2292
  for (const s of sentences(doc2)) {
2251
2293
  const words2 = normal(s.text);
2252
2294
  if (words2.length < RUN || !/[.!?]["'’”)]*$/.test(s.text)) continue;
2295
+ if (/^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
2253
2296
  const key = words2.join(" ");
2254
2297
  const earlier = whole.get(key);
2255
2298
  if (earlier) {
@@ -2447,7 +2490,15 @@ var DENSITY_TELLS = [
2447
2490
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nMore here.\n\nWe check every valve before the engineer leaves the house.")),
2448
2491
  f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nBy then we check every valve before the engineer leaves the site."))
2449
2492
  ],
2450
- pass: [f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet."))]
2493
+ pass: [
2494
+ f2("content/proposal.md", doc("We check every valve before the engineer leaves the house.\n\nThe engineer leaves once the customer has signed the sheet.")),
2495
+ f2(
2496
+ "content/guide.md",
2497
+ doc(
2498
+ "The Independent Healthcare Providers Network Private Patient Insight Report 2024 found that private demand rose.[^1]\n\n[^1]: Independent Healthcare Providers Network, Private Patient Insight Report 2024, published online."
2499
+ )
2500
+ )
2501
+ ]
2451
2502
  }
2452
2503
  },
2453
2504
  {
@@ -3484,6 +3535,9 @@ var HOUSE = {
3484
3535
  "closing-summary": { tier: "review", label: "closing summary" },
3485
3536
  "false-range": { tier: "review", label: "false range" },
3486
3537
  "placeholder": { tier: "review", label: "unfilled placeholder" },
3538
+ // Every estate hit so far is real (38 in five DD articles, 2026-09-24), but
3539
+ // a proposal can honestly say "the attached research". Review until measured there.
3540
+ "prompt-context": { tier: "review", label: "prompt context: the model naming its own inputs" },
3487
3541
  "question-reveal": { tier: "review", label: "staged reveal" },
3488
3542
  "inline-label-list": { tier: "review", label: "bold-label bullets" },
3489
3543
  "phrase-density": { tier: "review", label: "density" },
@@ -4166,7 +4220,7 @@ var RENDERED_PATHS = {
4166
4220
  };
4167
4221
 
4168
4222
  // src/character/check.ts
4169
- var CATALOGUE_VERSION = "2026.09.7";
4223
+ var CATALOGUE_VERSION = "2026.09.8";
4170
4224
  var CATALOGUE = [...SOURCE_TELLS, ...COPY_TELLS, ...RESEARCH_TELLS, ...DENSITY_TELLS].map(
4171
4225
  (t) => RENDERED_PATHS[t.id] ? { ...t, rendered: RENDERED_PATHS[t.id] } : t
4172
4226
  );
@@ -4431,6 +4485,308 @@ function formatComparison(result, beforePath, afterPath) {
4431
4485
  return lines.join("\n");
4432
4486
  }
4433
4487
 
4488
+ // src/character/claims.ts
4489
+ var CLAIM_KINDS = /* @__PURE__ */ new Set(["money", "number", "date"]);
4490
+ var PROPER = "[A-Z][\\w&.\u2019'-]*(?:\\s+(?:of|and|&|the|for)?\\s*[A-Z][\\w&.\u2019'-]*)*";
4491
+ var NOT_A_SOURCE = /^(?:We|I|It|This|That|These|Those|They|He|She|You|Our|Your|The|A|An|Each|Every|Most|Many|Some|One|Research|Studies|Experts|Data|Analysis|If|When|So|But|And|Or|In|On|At|From|For|By|As|Since|After|Before|Across|Over|Under|Its|Their|External|Source|UK|US|EU|Sometimes|Public|Recent|Industry|Independent|What|How|Why|Who|Where|Businesses|Customers|Clients|Patients|Consumers|People|Users|Online)$/;
4492
+ var NOUN = "study|survey|report|research|analysis|benchmark|benchmarks|guidelines|docs|documentation|help centre|help center|pricing page|figures|statistics|case study";
4493
+ var OWNED_NOUN = "guide|audit|support|pricing|data|site|website|blog";
4494
+ var VERB = "found|finds|reports?|reported|says|said|states?|stated|estimates?|estimated|recommends?|publishes|published|advertises|advertised|shows|showed|claims";
4495
+ var ATTRIBUTION = [
4496
+ new RegExp(`\\b[Aa]ccording to\\s+(?:the\\s+)?(${PROPER})`, "g"),
4497
+ new RegExp(`\\b(?:${NOUN})\\s+(?:by|from|commissioned by)\\s+(?:the\\s+)?(${PROPER})`, "g"),
4498
+ // "Akamai’s 2017 retail study", "the 2020 Deloitte study", "Bark’s UK guide", "Wix’s own developer guidelines".
4499
+ new RegExp(`\\b(${PROPER})(?:(?:\u2019s|'s)\\s+(?:own\\s+)?(?:\\d{4}\\s+)?(?:[A-Za-z]+\\s+)?(?:${NOUN}|${OWNED_NOUN})|\\s+(?:\\d{4}\\s+)?(?:[a-z]+\\s+)?(?:${NOUN}))\\b`, "g"),
4500
+ // "Google also reported", "Shopify UK currently advertises".
4501
+ new RegExp(`\\b(${PROPER})\\s+(?:also\\s+|now\\s+|currently\\s+|still\\s+)?(?:${VERB})\\b`, "g"),
4502
+ /\((?:[Ss]ources?:\s*|via\s+(?=[A-Z]))([^)]+)\)/g,
4503
+ /^\s*(?:source|sources)\s*:\s*(.+)$/gim
4504
+ ];
4505
+ var LINK2 = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)|\bhttps?:\/\/[^\s<>"')\]]+/g;
4506
+ function cleanName(raw) {
4507
+ const words2 = raw.trim().replace(/[.,;:]+$/, "").split(/\s+/);
4508
+ while (words2.length && NOT_A_SOURCE.test(words2[0])) words2.shift();
4509
+ return words2.join(" ").replace(/(?:’s|'s)(?=\s|$).*$/, "").trim();
4510
+ }
4511
+ var IMPERATIVE = /^(?:Add|Use|Check|Make|Get|Set|Include|Create|Update|List|Keep|Ask|Write|Track|Test|Claim|Build|Link|Submit|Verify|Review|Fix|Remove|Upload|Respond|Reply|Choose|Pick|Start|Run|Open|Install|Connect|Enable|Turn|Put|Give|Send|Book|Call|Read|See|Try|Compare|Aim|Target|Avoid|Post|Share|Mark|Tag)$/;
4512
+ var atStart = (sentence, index) => /^[\s*_"“”’(>#|-]*(?:\d{1,3}[.)]\s*)?[\s*_]*(?:[\w ]+:\**\s*)?$/.test(sentence.slice(0, index)) || /\|\s*\**$/.test(sentence.slice(0, index));
4513
+ function namedSources(sentence) {
4514
+ const found = /* @__PURE__ */ new Set();
4515
+ ATTRIBUTION.forEach((pattern, i) => {
4516
+ for (const m of sentence.matchAll(pattern)) {
4517
+ const raw = m[1].trim();
4518
+ const word = !/\s/.test(raw) && !/[’']s$/.test(m[0].split(/\s+/)[0]);
4519
+ if (i === 2 && word && atStart(sentence, m.index ?? 0)) continue;
4520
+ if (IMPERATIVE.test(raw.split(/\s+/)[0]) && atStart(sentence, m.index ?? 0)) continue;
4521
+ const name = cleanName(raw);
4522
+ if (!name || name.length < 2 || !/\s/.test(name) && /[a-z]ing$/.test(name)) continue;
4523
+ found.add(name);
4524
+ }
4525
+ });
4526
+ for (const m of sentence.matchAll(LINK2)) found.add(m[2] ?? m[0]);
4527
+ return [...found];
4528
+ }
4529
+ var FOOTNOTE_REF = /\[\^([\w-]+)\](?!:)/g;
4530
+ var FOOTNOTE_DEF = /^[ \t]*\[\^([\w-]+)\]:[ \t]*(.+)$/gm;
4531
+ var ASSUMPTION = /^(?:\*\*)?(?:assume|suppose|imagine|say|let's say|for example, say|picture)\b/i;
4532
+ var LEAD = /^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)*/;
4533
+ function sentences2(text) {
4534
+ const out = [];
4535
+ let start = 0;
4536
+ let paragraph = 0;
4537
+ const push = (end) => {
4538
+ const raw = text.slice(start, end);
4539
+ const lead = raw.length - raw.trimStart().length;
4540
+ if (raw.trim()) out.push({ text: raw.trim(), offset: start + lead, paragraph });
4541
+ };
4542
+ for (const m of text.matchAll(/(?<!(?:^|\n)[ \t]*\d{1,3})[.!?](?:\[\^[\w-]+\])*(?=["”’)]?[ \t]+["“(*]*[A-Z0-9£$€])|\n+/g)) {
4543
+ const end = (m.index ?? 0) + m[0].length;
4544
+ push(end);
4545
+ start = end;
4546
+ if (/\n\s*\n/.test(m[0]) || /^\s*(?:#|\|)/.test(text.slice(end))) paragraph += 1;
4547
+ }
4548
+ push(text.length);
4549
+ return out;
4550
+ }
4551
+ var MAX = 180;
4552
+ var trim = (s) => s.length > MAX ? `${s.slice(0, MAX - 1).trimEnd()}\u2026` : s;
4553
+ function findClaims(files) {
4554
+ const claims = [];
4555
+ for (const file of files) {
4556
+ for (const block of extractCopy(file)) {
4557
+ const notes = new Map([...block.text.matchAll(FOOTNOTE_DEF)].map((m) => [m[1], m[2].trim()]));
4558
+ let carried;
4559
+ for (const s of sentences2(block.text)) {
4560
+ if (/^\s*#{1,6}\s/.test(s.text) || /^\s*\[\^[\w-]+\]:/.test(s.text)) continue;
4561
+ const refs = [...s.text.matchAll(FOOTNOTE_REF)].map((m) => notes.get(m[1]) ?? `footnote ${m[1]}`);
4562
+ const text = s.text.replace(FOOTNOTE_REF, "");
4563
+ const body = text.replace(LEAD, "");
4564
+ const own = [...namedSources(text), ...refs];
4565
+ if (own.length) carried = { paragraph: s.paragraph, sources: own };
4566
+ if (ASSUMPTION.test(body)) continue;
4567
+ const facts = protectedFacts(body).filter((f4) => CLAIM_KINDS.has(f4.kind));
4568
+ if (facts.length === 0 && own.length === 0) continue;
4569
+ const nearby = own.length === 0 && carried?.paragraph === s.paragraph ? carried.sources : void 0;
4570
+ const at = block.offset + s.offset;
4571
+ claims.push({
4572
+ path: file.path,
4573
+ line: file.text.slice(0, at).split("\n").length,
4574
+ sentence: trim(text.replace(/\s+/g, " ")),
4575
+ facts,
4576
+ sources: own,
4577
+ ...nearby ? { nearby } : {}
4578
+ });
4579
+ }
4580
+ }
4581
+ }
4582
+ return { files: files.length, claims, unsourced: claims.filter((c) => c.sources.length === 0).length };
4583
+ }
4584
+ function formatClaims(report2, label = "craft copy claims") {
4585
+ const { claims, unsourced } = report2;
4586
+ if (claims.length === 0) return `${label}: no prices, figures, dates or named sources in ${report2.files} file${report2.files === 1 ? "" : "s"}.`;
4587
+ const lines = [
4588
+ `${label}: ${claims.length} claim${claims.length === 1 ? "" : "s"} to check, ${unsourced} with no source named.`,
4589
+ " Check each against its primary source before it ships. Sourced means a source is named, not that it agrees."
4590
+ ];
4591
+ for (const c of claims) {
4592
+ const what = [
4593
+ ...c.facts.map((f4) => f4.value),
4594
+ ...c.sources.map((s) => `source: ${s}`),
4595
+ ...c.nearby ? [`${c.nearby.join(", ")} named earlier in the paragraph: does it cover this?`] : []
4596
+ ].join(" \xB7 ");
4597
+ lines.push("", ` ${c.path}:${c.line} ${c.sources.length ? "sourced " : "UNSOURCED"} ${what}`, ` ${c.sentence}`);
4598
+ }
4599
+ return lines.join("\n");
4600
+ }
4601
+
4602
+ // src/character/baseline.ts
4603
+ var BASELINE_VERSION = 1;
4604
+ function fnv1a(text) {
4605
+ let hash = 2166136261;
4606
+ for (let i = 0; i < text.length; i += 1) {
4607
+ hash ^= text.charCodeAt(i);
4608
+ hash = Math.imul(hash, 16777619);
4609
+ }
4610
+ return (hash >>> 0).toString(16).padStart(8, "0");
4611
+ }
4612
+ var excerptHash = (f4) => fnv1a(f4.excerpt.replace(/\s+/g, " ").trim());
4613
+ var keyOf = (tell, path, excerpt) => `${tell}\0${path}\0${excerpt}`;
4614
+ function findingKey(f4) {
4615
+ return keyOf(f4.tell, f4.path, excerptHash(f4));
4616
+ }
4617
+ function createBaseline(report2) {
4618
+ const counts = /* @__PURE__ */ new Map();
4619
+ for (const f4 of report2.findings) {
4620
+ const excerpt = excerptHash(f4);
4621
+ const key = keyOf(f4.tell, f4.path, excerpt);
4622
+ const entry = counts.get(key);
4623
+ if (entry) entry.count += 1;
4624
+ else counts.set(key, { tell: f4.tell, path: f4.path, excerpt, count: 1 });
4625
+ }
4626
+ const entries = [...counts.values()].sort((a, b) => a.path.localeCompare(b.path) || a.tell.localeCompare(b.tell) || a.excerpt.localeCompare(b.excerpt));
4627
+ return { version: BASELINE_VERSION, catalogueVersion: report2.catalogueVersion, entries };
4628
+ }
4629
+ function parseBaseline(data, source = "baseline") {
4630
+ const b = data;
4631
+ if (!b || typeof b !== "object" || b.version !== BASELINE_VERSION || !Array.isArray(b.entries)) {
4632
+ throw new Error(`${source}: not a craft baseline (version ${BASELINE_VERSION})`);
4633
+ }
4634
+ for (const e of b.entries) {
4635
+ if (typeof e?.tell !== "string" || typeof e.path !== "string" || typeof e.excerpt !== "string" || !Number.isInteger(e.count) || e.count < 1) {
4636
+ throw new Error(`${source}: malformed entry ${JSON.stringify(e)}`);
4637
+ }
4638
+ }
4639
+ return b;
4640
+ }
4641
+ function recount(report2, findings) {
4642
+ const byGeneration = { 1: 0, 2: 0, 3: 0 };
4643
+ const byTell = {};
4644
+ for (const f4 of findings) {
4645
+ byGeneration[f4.generation] += 1;
4646
+ byTell[f4.tell] = (byTell[f4.tell] ?? 0) + 1;
4647
+ }
4648
+ return {
4649
+ ...report2,
4650
+ findings,
4651
+ summary: { ...report2.summary, findings: findings.length, byGeneration, byTell, blocking: findings.filter((f4) => f4.severity === "block").length }
4652
+ };
4653
+ }
4654
+ function applyBaseline(report2, baseline) {
4655
+ const remaining = /* @__PURE__ */ new Map();
4656
+ for (const e of baseline.entries) {
4657
+ const key = keyOf(e.tell, e.path, e.excerpt);
4658
+ remaining.set(key, (remaining.get(key) ?? 0) + e.count);
4659
+ }
4660
+ const kept = [];
4661
+ let baselined = 0;
4662
+ for (const f4 of report2.findings) {
4663
+ const key = findingKey(f4);
4664
+ const left = remaining.get(key) ?? 0;
4665
+ if (left > 0) {
4666
+ remaining.set(key, left - 1);
4667
+ baselined += 1;
4668
+ } else kept.push(f4);
4669
+ }
4670
+ return { ...recount(report2, kept), baselined };
4671
+ }
4672
+
4673
+ // src/character/config.ts
4674
+ var DEFAULT_IGNORE = [".claude", ".agents", ".cursor"];
4675
+ var KEYS = /* @__PURE__ */ new Set(["$schema", "ignore", "copyPaths", "severity"]);
4676
+ var LEVELS = /* @__PURE__ */ new Set(["warn", "block", "off"]);
4677
+ var MIN_BECAUSE = 12;
4678
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
4679
+ var isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
4680
+ function parseCraftConfig(data, knownTells, source = "craft.config.json") {
4681
+ if (!isRecord(data)) throw new Error(`${source}: expected an object`);
4682
+ for (const key of Object.keys(data)) if (!KEYS.has(key)) throw new Error(`${source}: unknown key "${key}"`);
4683
+ const ignore = data.ignore ?? [];
4684
+ if (!isStringArray(ignore)) throw new Error(`${source}: "ignore" must be an array of glob strings`);
4685
+ if (data.copyPaths !== void 0 && !isStringArray(data.copyPaths)) throw new Error(`${source}: "copyPaths" must be an array of paths`);
4686
+ const severity = {};
4687
+ const raw = data.severity ?? {};
4688
+ if (!isRecord(raw)) throw new Error(`${source}: "severity" must map a tell id to { level, because }`);
4689
+ for (const [tell, value] of Object.entries(raw)) {
4690
+ if (!knownTells.has(tell)) throw new Error(`${source}: severity names no tell called "${tell}"`);
4691
+ if (!isRecord(value) || !LEVELS.has(value.level)) {
4692
+ throw new Error(`${source}: severity.${tell} needs a level of "warn", "block" or "off"`);
4693
+ }
4694
+ if (typeof value.because !== "string" || value.because.trim().length < MIN_BECAUSE) {
4695
+ throw new Error(`${source}: severity.${tell} needs a because of at least a sentence`);
4696
+ }
4697
+ severity[tell] = { level: value.level, because: value.because };
4698
+ }
4699
+ return { ignore, severity, ...data.copyPaths ? { copyPaths: data.copyPaths } : {} };
4700
+ }
4701
+ function globToRegExp(glob) {
4702
+ const pattern = glob.replace(/^\.\//, "").replace(/\/$/, "").replace(/\/\*\*$/, "");
4703
+ let body = "";
4704
+ for (let i = 0; i < pattern.length; i += 1) {
4705
+ const c = pattern[i];
4706
+ if (c === "*" && pattern[i + 1] === "*") {
4707
+ const slash = pattern[i + 2] === "/";
4708
+ body += slash ? "(?:.*/)?" : ".*";
4709
+ i += slash ? 2 : 1;
4710
+ } else if (c === "*") body += "[^/]*";
4711
+ else if (c === "?") body += "[^/]";
4712
+ else body += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4713
+ }
4714
+ const anywhere = !pattern.includes("/");
4715
+ return new RegExp(`^${anywhere ? "(?:.*/)?" : ""}${body}(?:/.*)?$`);
4716
+ }
4717
+ function ignoreMatcher(patterns) {
4718
+ const res = patterns.map(globToRegExp);
4719
+ return (path) => {
4720
+ const p = path.replace(/\\/g, "/").replace(/^\.\//, "");
4721
+ return res.some((re) => re.test(p));
4722
+ };
4723
+ }
4724
+ function severityExceptions(config) {
4725
+ return Object.entries(config.severity).filter(([, o]) => o.level === "off").map(([tell, o]) => ({ tell, because: `craft.config.json: ${o.because}` }));
4726
+ }
4727
+ function applySeverity(report2, config) {
4728
+ const findings = report2.findings.map((f4) => {
4729
+ const o = config.severity[f4.tell];
4730
+ return o && o.level !== "off" ? { ...f4, severity: o.level } : f4;
4731
+ });
4732
+ return { ...report2, findings, summary: { ...report2.summary, blocking: findings.filter((f4) => f4.severity === "block").length } };
4733
+ }
4734
+
4735
+ // src/character/sarif.ts
4736
+ var isUrl = (path) => /^[a-z][a-z0-9+.-]*:\/\//i.test(path);
4737
+ function toSarif(report2, options) {
4738
+ const byId = new Map(options.tells.map((t) => [t.id, t]));
4739
+ const ids = [...new Set(report2.findings.map((f4) => f4.tell))].sort();
4740
+ const index = new Map(ids.map((id, i) => [id, i]));
4741
+ const rules = ids.map((id) => {
4742
+ const t = byId.get(id);
4743
+ return {
4744
+ id,
4745
+ name: t?.name ?? id,
4746
+ shortDescription: { text: t?.name ?? id },
4747
+ ...t ? { fullDescription: { text: t.why }, help: { text: t.fix } } : {}
4748
+ };
4749
+ });
4750
+ const results = report2.findings.map((f4) => ({
4751
+ ruleId: f4.tell,
4752
+ ruleIndex: index.get(f4.tell),
4753
+ level: f4.severity === "block" ? "error" : "warning",
4754
+ message: { text: `${f4.message}. ${f4.fix}` },
4755
+ locations: [
4756
+ {
4757
+ physicalLocation: {
4758
+ artifactLocation: { uri: isUrl(f4.path) ? f4.path : f4.path.replace(/\\/g, "/") },
4759
+ ...f4.line > 0 ? { region: { startLine: f4.line, snippet: { text: f4.excerpt } } } : {}
4760
+ }
4761
+ }
4762
+ ],
4763
+ partialFingerprints: { "craftFinding/v1": fnv1a(findingKey(f4)) }
4764
+ }));
4765
+ return {
4766
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
4767
+ version: "2.1.0",
4768
+ runs: [
4769
+ {
4770
+ tool: {
4771
+ driver: {
4772
+ name: "craft",
4773
+ version: report2.catalogueVersion,
4774
+ informationUri: options.informationUri ?? "https://www.npmjs.com/package/@domandigital/craft",
4775
+ rules
4776
+ }
4777
+ },
4778
+ results
4779
+ }
4780
+ ]
4781
+ };
4782
+ }
4783
+
4784
+ // src/character/json.ts
4785
+ var JSON_SCHEMA_VERSION = 1;
4786
+ function toJson(data) {
4787
+ return JSON.stringify({ schemaVersion: JSON_SCHEMA_VERSION, ...data }, null, 2);
4788
+ }
4789
+
4434
4790
  // src/fingerprint/index.ts
4435
4791
  var FINGERPRINT_VERSION = 1;
4436
4792
  var CHROMATIC = 0.03;
@@ -5325,10 +5681,12 @@ export {
5325
5681
  AI_PHRASES,
5326
5682
  AI_VIOLET,
5327
5683
  AI_WORDS,
5684
+ BASELINE_VERSION,
5328
5685
  BUZZWORDS,
5329
5686
  CATALOGUE,
5330
5687
  CATALOGUE_VERSION,
5331
5688
  CHOICE_KEYS,
5689
+ DEFAULT_IGNORE,
5332
5690
  DENSITIES,
5333
5691
  DENSITY_NAMES,
5334
5692
  DIRECTION_VERSION,
@@ -5343,6 +5701,7 @@ export {
5343
5701
  HOUSE,
5344
5702
  HOUSE_BUDGET,
5345
5703
  HOUSE_TYPE,
5704
+ JSON_SCHEMA_VERSION,
5346
5705
  LIGHTNESS_CURVE,
5347
5706
  MIN_DECIDED,
5348
5707
  MIN_RUNS,
@@ -5373,7 +5732,9 @@ export {
5373
5732
  accentFork,
5374
5733
  addSite,
5375
5734
  apcaContrast,
5735
+ applyBaseline,
5376
5736
  applyHouseGate,
5737
+ applySeverity,
5377
5738
  auditSnapshot,
5378
5739
  catalogueTable,
5379
5740
  characterReport,
@@ -5384,6 +5745,7 @@ export {
5384
5745
  compareFacts,
5385
5746
  compareToEstate,
5386
5747
  craftTokens,
5748
+ createBaseline,
5387
5749
  deltaEOk,
5388
5750
  deltaEOkHex,
5389
5751
  densityCss,
@@ -5393,22 +5755,27 @@ export {
5393
5755
  estatePairs,
5394
5756
  exitDuration,
5395
5757
  extractHtml,
5758
+ findClaims,
5396
5759
  findColours,
5760
+ findingKey,
5397
5761
  fingerprint,
5398
5762
  fingerprintDistance,
5399
5763
  fluidClamp,
5400
5764
  fluidSpace,
5401
5765
  fluidType,
5402
5766
  fontClass,
5767
+ formatClaims,
5403
5768
  formatComparison,
5404
5769
  formatHex,
5405
5770
  formatReport,
5771
+ globToRegExp,
5406
5772
  groundName,
5407
5773
  harvest,
5408
5774
  hexToOklch,
5409
5775
  houseRule,
5410
5776
  hueName,
5411
5777
  hueSwatch,
5778
+ ignoreMatcher,
5412
5779
  inP3Gamut,
5413
5780
  inSrgbGamut,
5414
5781
  initDirection,
@@ -5416,13 +5783,16 @@ export {
5416
5783
  isCream,
5417
5784
  makeSnapshot,
5418
5785
  motionTokens,
5786
+ namedSources,
5419
5787
  nearest,
5420
5788
  normaliseFamily,
5421
5789
  nullPrompt,
5422
5790
  oklchToHex,
5423
5791
  oklchToRgb,
5424
5792
  paletteFromPixels,
5793
+ parseBaseline,
5425
5794
  parseColour,
5795
+ parseCraftConfig,
5426
5796
  parseHex,
5427
5797
  propose,
5428
5798
  protectedFacts,
@@ -5433,6 +5803,7 @@ export {
5433
5803
  scanSource,
5434
5804
  sectionRhythm,
5435
5805
  semantic,
5806
+ severityExceptions,
5436
5807
  shapeName,
5437
5808
  sharedParts,
5438
5809
  shouldAnimate,
@@ -5441,6 +5812,8 @@ export {
5441
5812
  tailwindV4Theme,
5442
5813
  tellById,
5443
5814
  toGamut,
5815
+ toJson,
5816
+ toSarif,
5444
5817
  typeFeatureTokens,
5445
5818
  typicality,
5446
5819
  validateDirection,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@domandigital/craft",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "The Doman Digital house craft standard as numbers: OKLCh colour ramps that snap to existing anchors, derived semantic tokens with measured WCAG and APCA contrast, one shared motion vocabulary for CSS and JS, and a base stylesheet. Zero dependencies.",
6
6
  "license": "Apache-2.0",
@@ -8,21 +8,28 @@
8
8
  "main": "dist/index.cjs",
9
9
  "module": "dist/index.js",
10
10
  "types": "dist/index.d.ts",
11
+ "sideEffects": [
12
+ "./css/*.css"
13
+ ],
11
14
  "bin": {
12
15
  "craft": "dist/cli.js"
13
16
  },
14
17
  "exports": {
15
18
  ".": {
16
- "types": "./dist/index.d.ts",
17
- "import": "./dist/index.js",
19
+ "import": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
18
23
  "require": {
19
24
  "types": "./dist/index.d.cts",
20
25
  "default": "./dist/index.cjs"
21
26
  }
22
27
  },
23
28
  "./audit": {
24
- "types": "./dist/audit.d.ts",
25
- "import": "./dist/audit.js",
29
+ "import": {
30
+ "types": "./dist/audit.d.ts",
31
+ "default": "./dist/audit.js"
32
+ },
26
33
  "require": {
27
34
  "types": "./dist/audit.d.cts",
28
35
  "default": "./dist/audit.cjs"
@@ -52,7 +59,7 @@
52
59
  "vitest": "^2.1.9"
53
60
  },
54
61
  "engines": {
55
- "node": ">=20"
62
+ "node": ">=22.12"
56
63
  },
57
64
  "repository": {
58
65
  "type": "git",
package/word-lists.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "catalogueVersion": "2026.09.7",
2
+ "catalogueVersion": "2026.09.8",
3
3
  "lists": {
4
4
  "aiWords": [
5
5
  "tapestry",