Contributing
Add a site check
Add a check to the shared site scan, as an SEO & GEO site health rule or a Security page or site check, with its data source, severity, fix text, grouping, tests, demo data and docs.
Accessibility, SEO & GEO and Security share one crawl: each page is loaded once in a real Chromium and every module’s checks run on the result. This page shows how to add a check to that scan: where the data a check needs comes from, how to add an SEO & GEO site health rule or a Security check, how findings are grouped and scored, and what to test and document. It also explains why accessibility checks work differently.
Warning
Checks only read what any visitor’s browser receives. Open a Discussion before you add a check that needs a new kind of request (a new file path, a DNS lookup, a third-party service), and never add one that sends payloads, submits forms, clicks, tries passwords or crawls beyond the site’s own pages. See Rules for every check.
How a check runs
The scan code is in packages/core/src/a11y/scan.ts. For each page:
checkPage()asks the scanner backend to open the page withscanRequest(). Both backends (Electron in the desktop app, Playwright in browser development mode and tests) run the same in-page script, built in core byrunScript()ina11y/scanner.ts.- The backend returns a
ScanResponse: the page’s status, the in-page script’sPageResult, and some of the main document’s response headers. storePageResult()stores the accessibility results, then, in the same transaction, callsstorePageSeo()(seo/site.ts),storePageSecurity()(security/findings.ts) andstorePageContent()(a11y/content.ts). Each replaces that page’s previous findings.
When the scan ends (a full scan, a page check, a recheck, or a stopped scan), finishScan() calls finishSeo() for the cross-page and site-level SEO checks, then finishSecurity(), which runs the Security site probes on full scans only (probes: scope === "full") and folds every occurrence into findings.
From a11y/scanner.ts:
export type ScanResponse = {
httpStatus: number | null;
finalUrl: string;
result: PageResult;
/** The page's X-Robots-Tag header, if any. */
xRobotsTag?: string | null;
/** A JPEG of the page (base64) when it had issues and the request asked for one, with its size. */
screenshot?: { data: string; width: number; height: number } | null;
/** The main document's security-relevant response headers (`RESPONSE_HEADERS`), lowercase names; Set-Cookie lines joined with "\n". */
headers?: Record<string, string>;
};
Pages that answer HTTP 400 or higher, redirect off the site or are blocked by robots.txt never reach storePageResult(). They’re marked failed or skipped in site_pages, which is how finishSeo() finds broken links.
Where the data comes from
Pick the cheapest source that has what your check needs:
| Source | What it has | Read it in |
|---|---|---|
| The in-page script | PageResult: title, lang, links, axe results, seo (SeoFacts), security (SecurityFacts), the main text, images |
pageFindings() (SEO), pageSecurityFindings() (Security), pageContent() (content quality) |
| Response headers | The headers named in RESPONSE_HEADERS, as response.headers; X-Robots-Tag as response.xRobotsTag |
pageSecurityFindings(); pageSeo() for X-Robots-Tag |
| Stored pages | Every page’s row in site_pages: state, HTTP status, seo, links, in_sitemap |
finishSeo(), for checks across pages (duplicates, broken links, orphans) |
| A site-level request | Files and answers that aren’t a crawled page | siteFiles() and aiCrawlerAccess() in seo/site.ts; siteSecurityProbes() in security/probes.ts |
Adding a fact to the in-page script
If your check needs something from the DOM that PageResult doesn’t have yet, add a field to SeoFacts or SecurityFacts in a11y/scanner.ts and fill it in runScript(). The existing facts show the pattern:
const security = {
// …
forms: [...document.forms].slice(0, 20).map((f) => ({ action: abs(f.getAttribute("action") || location.href), hasPassword: !!f.querySelector('input[type="password" i]') })),
scripts: [...document.querySelectorAll("script[src]")].slice(0, 50).map((s) => ({ src: abs(s.getAttribute("src") || ""), integrity: !!s.getAttribute("integrity") })).filter((s) => s.src),
generator: meta('meta[name="generator" i]'),
directoryListing: /^(Index of \\/|Directory listing for \\/)/.test(document.title || ""),
// …
};
- Cap every list and string (
.slice(0, 20),limit(text, 200)). The result is serialized as JSON and sent over IPC for every page. - Only read the DOM. No clicks, no form submission, no extra requests from the page.
runScript()returns a template string, so a regular expression’s backslashes are doubled (\\/,\\s), as in the excerpt.- Keep the decision in core. Return raw facts and judge them in the check function (JSON-LD is returned as text and parsed by
readStructuredData()), so the logic can be unit-tested without a browser.
Both backends run the script unchanged, so nothing changes in apps/desktop. Run the desktop smoke test anyway: it’s what proves the Electron scanner returns the new field.
Adding a response header
Add the header’s lowercase name to RESPONSE_HEADERS in a11y/scanner.ts. pickHeaders() keeps only those names: the Playwright backend calls it on the page’s headers, and the Electron main process sends every header of the main document, which apps/desktop-web/src/lib/scanner.ts filters with pickHeaders() too. Repeated headers are joined with ", ", and Set-Cookie lines with "\n".
Accessibility
There are no pluggable accessibility rules. The page checks are axe-core’s own rules, run unmodified at a pinned version (4.14.0). Startica doesn’t add rules to axe or rewrite its results. What you can change:
- Which axe rules run comes from the site’s profile:
profileTags()ina11y/scanner.ts(WCAG 2.2 A and AA by default; AAA and best practices when the site turns them on). A missing or wrong axe rule is a report to axe-core upstream. - How results become issues is in
a11y/results.ts:findings()turns each failing element into an occurrence,fingerprint()groups the same rule on the same element markup across pages (ids,data-andstyleattributes and query strings are stripped bynormalizeHtml()), andpageScore()weighs failures by impact (critical 10, serious 5, moderate 2, minor 1). - Content quality (A5) is Startica’s own code, in
a11y/content.ts.pageContent()produces findings of three fixed kinds, stored insite_findings:spelling,readabilityandrule. Therulekind runs the owner’s own content rules (text contains or missing, selector exists or missing, links to a domain), which are data onsites.content_rules, set on the Content screen. They aren’t code you add. - PDF checks are listed in
DOCUMENT_RULESina11y/documents.tsand produced bydocumentProblems()from whatanalyzePdf()reads with pdf.js. A new PDF check is an entry there, a line indocumentProblems(), and a field inDocumentChecks(packages/db/src/schema.ts) if it needs somethinganalyzePdf()doesn’t read yet.
A new kind of content finding means a new value in the site_findings.kind enum, a new tab on the Content screen (apps/desktop-web/src/app/(app)/accessibility/content/page.tsx) and new tests in a11y/content.test.ts. That’s a feature rather than a check, so start with a Discussion.
Add an SEO & GEO rule
Site health rules are in packages/core/src/seo/. Findings are stored in seo_findings and replaced on every scan; they have no open or fixed state of their own.
Step 1: Describe the rule
Add an entry to SEO_RULES in seo/rules.ts. Its key is the rule id stored in seo_findings.rule, and its text is shown as written on the Site health screen and in Opportunities:
export type SeoSeverity = "high" | "medium" | "low" | "info";
export type SeoCategory = "indexing" | "basics" | "links" | "structured" | "ai";
export type SeoRule = { title: string; severity: SeoSeverity; category: SeoCategory; why: string; fix: string };
export const SEO_RULES = {
// …
"h1-missing": {
title: "No main heading (h1)",
severity: "medium",
category: "basics",
why: "The h1 tells search engines, AI models and screen readers what the page is about.",
fix: "Add one h1 that names the page's topic.",
},
// …
} satisfies Record<string, SeoRule>;
Pick the severity by what happens if the problem stays:
| Severity | Use it when | Points off a page’s score |
|---|---|---|
high |
It’s likely to keep pages out of search results or AI answers | 15 |
medium |
It hurts how pages are shown or understood | 7 |
low |
It’s a smaller improvement | 2 |
info |
It’s a fact worth knowing, often intended (noindex, canonical-elsewhere) |
0 |
The weights are SEVERITY_WEIGHT. A page scores 100 minus its findings’ weights (seoPageScore()). The site score is the average page score, less two thirds of the weight of each site-wide finding (no page_id) and each broken-internal-link, at most 30 points. Every rule that isn’t info also becomes an opportunity on SEO & GEO → Opportunities automatically (opportunities/collect.ts); info rules are listed under Good to know on Site health.
Step 2: Produce it
A check on one page goes in pageFindings() in seo/checks.ts. It gets the page’s address, title, whether the sitemap lists it, its PageSeo and its raw JSON-LD blocks, and returns drafts. It’s a pure function:
export function pageFindings(page: { url: string; title: string | null; inSitemap: boolean }, seo: PageSeo, jsonLd: string[]): SeoFindingDraft[] {
const out: SeoFindingDraft[] = [];
const add = (rule: SeoRuleId, detail: Record<string, unknown> = {}, groupKey: string = rule) => out.push({ rule, groupKey, detail });
// …
if (seo.h1.length === 0) add("h1-missing");
else if (seo.h1.length > 1) add("h1-multiple", { headings: seo.h1 });
// …
for (const gap of data.incomplete) add("jsonld-incomplete", gap, `jsonld-incomplete:${gap.type}`);
return out;
}
If the check needs a new fact, add it to PageSeo (packages/db/src/schema.ts) and fill it in pageSeo(), which builds it from the in-page SeoFacts. PageSeo is stored as JSON in site_pages.seo, so a new field needs no migration, but pages scanned before the change won’t have it until they’re scanned again.
A check across pages or on the whole site goes in finishSeo() in seo/site.ts, using its add(pageId, rule, detail, groupKey) helper with null as the page for a site-wide finding:
if (!pages.some((p) => p.inSitemap)) add(null, "sitemap-missing");
Then add the rule to SITE_RULES at the bottom of the file. finishSeo() deletes only those rules before writing new ones, so a rule missing from the list piles up a copy on every scan.
A check that needs a request goes in siteFiles() next to llms.txt, robots.txt and the soft-404 probe. Use fetchText(): it sends Startica’s scan user agent, times out after 15 seconds, and returns null for reserved test hosts (.example, .test, .invalid), so the demo’s fieldnote.example never reaches the network. Decide what null means for your check. Sites often answer every unknown address with their app’s HTML page, so don’t treat a 200 as proof that a file exists (isTextFile() checks for a non-HTML answer).
Step 3: Show it
The Site health screen (apps/desktop-web/src/app/(app)/seo/health/page.tsx) shows every rule’s title, why and fix with no changes. Two things need code:
- A new category needs an entry in
CATEGORIESon that page, or its findings won’t be listed. - Details (a length, the headings found, the crawlers blocked) are shown by
detailLine()for page findings and inRuleCardfor grouped ones. Add a line for your rule if itsdetailsays something the title doesn’t.
Add a Security check
Security checks are in packages/core/src/security/. They run only for sites whose ownership is verified: storePageSecurity() and finishSecurity() return straight away otherwise. Each check writes occurrences (security_occurrences), and finishSecurity() folds them into findings (security_findings) with a lifecycle that survives scans.
Step 1: Describe the rule
Add an entry to RULES in security/rules.ts:
export type SecurityRule = {
title: string;
severity: SecuritySeverity;
category: SecurityCategory;
/** Checked on every page (grouped into one finding listing the pages), or once for the site. */
scope: "page" | "site";
why: string;
fix: string;
/** OWASP Top 10 (2021) category, when one fits. */
owasp?: OwaspId;
/** Related controls, for an auditor's reference (never a claim of conformance). */
controls: ControlId[];
};
A real one:
// --- Exposed files (one quiet GET each, on verified sites only)
"exposed-git": {
title: "The site's Git folder is public",
severity: "critical",
category: "exposure",
scope: "site",
why: "Anyone can download the source code and its history, often with passwords and keys that were committed at some point.",
fix: "Block /.git/ in the server settings (or deploy without it), then change any secret that was ever in the repository.",
owasp: "A05",
controls: LEAK,
},
scopedecides what Recheck does on the finding: asitefinding enqueuessecurity_recheck, which runs the site probes again; apagefinding starts a page check on its pages.owaspandcontrolsappear in the report as references for the owner’s auditor. Use the existing ids inOWASPandCONTROLS, or the shared lists (TRANSPORT,CONFIG,LEAK).categorygroups findings on the screens. A new one needs a label inCATEGORY_LABEL; the type makes that a compile error if you forget.
Severity, and the points each open finding takes off the site’s 100-point score (SEVERITY_WEIGHT, counted once per finding, not per page):
| Severity | Use it when | Points |
|---|---|---|
critical |
Something sensitive is exposed right now | 30 |
high |
Visitors’ data can be read or changed on the way | 15 |
medium |
A protection most sites should have is missing | 7 |
low |
It’s hardening worth doing | 2 |
info |
It’s worth knowing; not counted | 0 |
Severity also drives what happens next: critical and high findings become Today moves, and the issue tracker’s automatic setting opens issues at or above the severity the owner picked.
Step 2a: A page check
Page checks go in pageSecurityFindings() in security/checks.ts, a pure function over the page’s address, its response headers and its SecurityFacts. Nothing in checks.ts makes a request. The cookie checks show the pattern, including the group key:
for (const c of parseCookies(headers["set-cookie"])) {
if (https && !c.secure) add("cookie-not-secure", { cookie: c.name }, `cookie-not-secure:${c.name}`);
if (!c.httpOnly) add("cookie-not-httponly", { cookie: c.name }, `cookie-not-httponly:${c.name}`);
if (c.sameSite === null) add("cookie-no-samesite", { cookie: c.name }, `cookie-no-samesite:${c.name}`);
}
storePageSecurity() calls it with response.headers and response.result.security and stores the drafts as the page’s occurrences. If you need a header that isn’t there yet, see Adding a response header.
Step 2b: A site check
Site checks run once per full scan (and on Recheck) in siteSecurityProbes() in security/probes.ts. Keep the request in probes.ts and the decision in a pure function in checks.ts, as the exposed-file checks do:
export const EXPOSED_FILES = [
{ kind: "git", path: "/.git/HEAD", rule: "exposed-git" },
{ kind: "env", path: "/.env", rule: "exposed-env" },
{ kind: "ds_store", path: "/.DS_Store", rule: "exposed-ds-store" },
] as const satisfies readonly { kind: "git" | "env" | "ds_store"; path: string; rule: SecurityRuleId }[];
for (const file of EXPOSED_FILES) {
const target = new URL(file.path, url.origin).toString();
if (exposedFile(file.kind, await probe(target))) drafts.push({ rule: file.rule, groupKey: file.rule, detail: { url: target } });
}
exposedFile() recognizes each file by its content, not by a 200: a site that answers every path with its HTML page must not be reported. Do the same for any file you look for.
Use the helpers that are already there:
probe(url, follow?)is a GET with Startica’s scan user agent and a 10-second timeout that keeps at most the first 64 KB. It doesn’t follow redirects unless asked, because a redirected file isn’t exposed.- Reserved hosts (
.example,.test,.invalid) return before any request, so the demo makes none. - Local hosts (
isPrivateHost()) skip HTTPS, TLS, the redirect and email checks. Skip yours there too if it doesn’t apply to a site on the owner’s own computer or network.
Site-level drafts are stored with no page and replaced each time the probes run.
Step 3: Show it
The Findings list, a finding’s page and the Markdown report take the title, why, fix, area, OWASP category and controls from the rule (findingInfo() in security/report.ts). A finding’s details are shown as one line by describeDetail() in the same file, which reads a fixed set of keys, among them cookie, header and value, host, generator, url, urls, maxAge, protocol, validTo, error, domain, problems and policy. Reuse those keys in your detail where they fit; otherwise add a case to describeDetail().
Groups and fingerprints
A problem the owner fixes once should be one finding, however many pages show it. Each module groups differently:
| Module | Grouped by | Lifecycle |
|---|---|---|
| Accessibility | fingerprint(kind, ruleId, html): the axe rule plus the element’s normalized markup |
Open → fixed when no page has it; reopens if it comes back; ignore decisions are kept by fingerprint |
| SEO & GEO | group_key on each finding; findings are replaced each scan |
None: the latest scan’s findings are the list |
| Security | group_key: one finding per key across all pages |
Open → fixed when no longer seen; reopens; accepted risk and false positive survive scans |
For SEO and Security the group key defaults to the rule id, which is right when the fix is the same everywhere (a missing header is usually set once at the server or CDN). Add the distinguishing value when there can be several separate fixes: the cookie name (cookie-not-secure:session), the script’s host (third-party-script-no-sri:cdn.fieldnote.example), the header and its value (server-version:x-powered-by:standin/1.2), the duplicated text (title-duplicate:<title>), the broken address (broken:<url>) or the JSON-LD type (jsonld-incomplete:Product).
Keep the key stable between scans: lowercase it, cut long values to a fixed length (the server-version key uses value.slice(0, 120).toLowerCase()), and never put a page address in a page check’s key, or every page becomes its own finding. A Security finding keeps the detail of the first occurrence folded into it, so put page-specific details in each occurrence; a finding’s page lists its pages with their own details (findingPages()).
Rules for every check
These come from ARCHITECTURE.md at the repo root (sections 3, 9.1, 10.1, 11.1 and 11.3) and apply to every check:
- Never claim a result. Findings are “found by automated checks”. Never call a site “compliant”, “certified”, “secure” or “pentested” in a rule’s text, a detail line, a report or the docs. A site with no findings has “no problems found by the checks”. The Security report test fails if the report says “compliant”, “certified” or “pentested”.
- Defensive only, for Security. Read what any visitor’s browser receives: pages, response headers, cookies, certificates, public DNS and a few well-known file paths. Plain GETs, a TLS handshake and DNS lookups only. No exploit payloads, fuzzing, form submission, login attempts, or crawling beyond the site’s own pages.
- Polite requests. The crawl loads two pages at a time, at most one a second per site. Page checks add no requests; they read what the crawl already loaded. Site-level requests run once at the end of a scan: keep them to a handful of GETs with a timeout, a size cap and Startica’s scan user agent, and only to the site being scanned. Security has two approved exceptions (the public data services in exposure monitoring, and a GET of each third-party script the site’s own pages load); any new destination needs a Discussion and the owner’s go-ahead.
- Verified sites only, for Security. Don’t run a security check from anywhere that skips
isVerified(). - Startica suggests, the owner fixes. A check never changes the site, its DNS or its files.
Writing the text
titlenames the problem as the owner sees it: “Cookie readable by scripts (no HttpOnly)”, “Page is only in the sitemap: nothing links to it”.whysays what goes wrong for visitors, search engines or the owner if it stays, in one or two sentences.security.test.tschecks that every Security rule’swhyhas more than 20 characters and itsfixmore than 10.fixsays what to change, with the exact header, value or file where there is one (“Send Strict-Transport-Security: max-age=31536000”). Name the usual place to change it (server, CDN, framework) without assuming one vendor.infofixes start with “Nothing, if that’s intended” when the fact is often a choice.- Plain words, no jargon the title doesn’t explain, and no real companies or products in examples: use Fieldnote and
fieldnote.example.
Test it
Unit tests
Test the pure function with small hand-built inputs:
-
SEO page checks in
seo/seo.test.ts, with itsseo()helper for aPageSeo:const rules = (title: string | null, over = {}, inSitemap = false) => pageFindings({ url: "https://fieldnote.example/pricing", title, inSitemap }, seo(over), []).map((f) => f.rule); expect(rules("Pricing: Fieldnote", { robots: "noindex, follow" }, true)).toEqual(["noindex-in-sitemap"]);Add your rule’s case and one where it must not fire. The “site files” test shows how to test a site-level request: it starts two local
node:httpservers (a single-page app that answers every path with HTML, and a well-behaved site) and checkssiteFiles()against both. -
Security page checks in
security/security.test.ts, withpageSecurityFindings()over a header object andnoFacts, comparing group keys. Start fromgood, the header set with no findings, and change one thing. Site checks test the pure function (exposedFile(),parseSecurityTxt(),tlsFindings()) with fixed inputs and a fixedNOW. Add a case to “scores open findings once each, by severity” if your severity changes the expected numbers.
Unit tests make no requests except to servers they start on 127.0.0.1.
The stand-in site
packages/core/src/a11y/standin/server.mjs is a small static site with planted problems (see Testing). Plant a case for your check:
- In a page: edit one of the HTML files. Keep
/clean.htmlfree of every problem: it’s the page that must score 100 and send every security header. - In a response header: change
standinHeaders(path). - In a site-level file: add a branch in the request handler, as
/.git/HEADand/robots.txthave.
Tests can serve a fixed version of a page without touching the files, through startStandin()'s transform(path, html) and headers(path, planted) options. That’s how security.test.ts checks that a fixed cookie closes its findings.
Then update every place that expects the stand-in’s exact results:
| Where | Expects |
|---|---|
packages/core/src/a11y/scan.test.ts |
The exact SEO findings (rule@path), and the 7 accessibility issue groups |
packages/core/src/security/security.test.ts |
The exact open Security group keys with page counts (11), and the score |
apps/desktop-web/scripts/smoke.mjs |
7 open accessibility issues, a few Site health titles, and 11 open Security findings (12 if the monitor has already run) |
apps/desktop/scripts/smoke.mjs |
7 accessibility issues and exactly 11 open Security findings, through the Electron scanner |
apps/docs/content/contributing/testing.md |
The stand-in’s planted problems and the counts |
Demo data
The demo product’s site, fieldnote.example, is seeded through the same code as a real scan: seedDemoAccessibility() in packages/core/src/demo/accessibility.ts calls storePageResult() for each page with a hand-built PageResult and response headers, then finishScan(). So:
- A new page check runs on the demo by itself. Check what it reports there and change the demo’s
result(),security()orheaders()if it should look different. - A site-level check sees no responses, because nothing is requested for a reserved host. Seed what the request would have found:
seedDemoSecurity()inserts site-levelsecurity_occurrences(security-txt-missing,dmarc-none) before folding them, and the SEO seed inserts itsai-training-blockedfinding directly.
packages/core/src/demo/seed.test.ts expects the demo’s open Security group keys and its security score, so update it with the demo. To look at the result, load the demo into an isolated data folder with STARTICA_DATA_DIR="$HOME/startica-demo" yarn seed:demo --force (see Development setup).
Document it
In the same pull request:
- Add the check to the user-facing list: Security checks (the table for its area, with its severity) or Site health (check, severity, when it’s reported, how to fix). Content and PDF checks are described in Content and documents.
- Update the stand-in tables and counts in Testing.
- Update the module’s section in
ARCHITECTURE.mdat the repo root (10.2 or 11.2) if it lists the checks.
Before you open a pull request
Run the checks from the repo root:
yarn typecheck && yarn lint && yarn test && yarn smoke:desktop
yarn workspace @startica/desktop smoke
yarn workspace @startica/docs check
yarn test includes the end-to-end scan tests, which skip themselves when Playwright’s Chromium isn’t installed; install it (yarn workspace @startica/desktop-web playwright install chromium) so they run. The desktop smoke test is the only one that scans through the Electron scanner, and it expects the stand-in’s exact Security count. The docs check fails on a page missing from the sidebar or a broken internal link.
In the pull request, say what the check reads, which requests it adds (if any), and what it reports on the stand-in and on the demo.