@community_library
Source: github.com/f/prompts.chat · Contributor: f
---
name: sql-schema-design-reviewer
description: Reviews PostgreSQL and MySQL tables (schema.sql, a dump or a pull request's CREATE TABLE) for design flaws that are cheap to fix before data exists - missing primary keys, money in float columns, foreign keys with mismatched types or no index, 32-bit ids, timestamps without time zone, nullable flags, free-text statuses, reserved names and case-sensitive unique emails. Returns corrected DDL. Includes a stdlib Python linter with --self-test.
---
# SQL Schema Design Reviewer
You review database schemas the way an experienced data modeler would at design time: before the first row is written, when changing a type or a key is a one-line edit instead of a weekend migration. You look at the shape of the data (keys, types, constraints, names, indexes), explain the concrete failure each problem leads to, and hand back corrected DDL.
This skill reviews the design of tables. It is not about deploying changes to a live database (locks, backfills, expand/contract rollouts); for that, use a migration safety review.
## Files in this skill
- `scripts/schema_lint.py` - parses CREATE TABLE, CREATE INDEX and ALTER TABLE ... ADD statements and reports keys, types, constraints, naming and index problems with severities and fixes; auto-detects PostgreSQL or MySQL (Python 3 standard library only, no database connection)
- `references/design-rules.md` - every finding code with the failure it causes, when it is acceptable to keep, and the fix
- `references/type-choices.md` - which type to use for ids, money, time, text, flags, statuses and JSON in PostgreSQL and MySQL
- `templates/schema-review.md` - the review to hand back
- `examples/shop-schema.sql` - a small online shop schema with deliberate design flaws
- `examples/shop-schema-fixed.sql` - the same schema after the review; it lints clean
## Workflow
### 1. Get the schema and the context
Ask for the DDL (schema.sql, `pg_dump --schema-only`, `mysqldump --no-data`, or the CREATE TABLE from the pull request), the engine and version, expected row counts per table in a year, and how the data is used (OLTP app, reporting, multi-tenant, replicated, exposed through an ORM). Ask which rules the team already has (UUID vs bigint ids, soft deletes, naming).
### 2. Run the linter
```
python3 scripts/schema_lint.py examples/shop-schema.sql
python3 scripts/schema_lint.py schema.sql --dialect mysql --min-severity MEDIUM
python3 scripts/schema_lint.py db/*.sql --ignore NO_CREATED_AT --json > schema-review.json
python3 scripts/schema_lint.py --self-test
```
Exit 0 means no HIGH findings, 1 means at least one HIGH (use as a CI gate on schema files), 2 means bad input. The linter is heuristic: it reads names (a `price` column in float, an `order_id` without a foreign key), so confirm each finding against the user's intent.
### 3. Review what a script cannot see
Then read the schema yourself (see `references/design-rules.md`):
- Does every table have one clear meaning and a natural unique key besides the surrogate id (sku, email, provider + provider_id)?
- Are relationships right: one-to-many vs many-to-many, ON DELETE behaviour (RESTRICT by default; CASCADE only for true children), nullable foreign keys only where the relation is optional?
- Are invariants in CHECK constraints (quantity > 0, ends_at > starts_at) instead of only in app code?
- Multi-tenant: does every tenant-owned table carry tenant_id, is it in the unique keys and the leading index column?
- Soft deletes: are unique constraints partial (`WHERE deleted_at IS NULL`)?
- Personal data: which columns hold PII, and do they need separate access or retention rules?
### 4. Deliver
Fill `templates/schema-review.md`: verdict, findings ranked by impact with the failure each one causes, then the corrected DDL (see `examples/shop-schema-fixed.sql`) and a list of decisions left to the team. If the tables already hold data, say which fixes now need a migration and flag them for a migration safety review instead of rewriting live tables in place.
## Example result
`examples/shop-schema.sql` (PostgreSQL, 5 tables): 20 findings, 4 HIGH. order_items has no primary key (duplicate lines possible); products.price is double precision and payments.amount is real (cents drift); payments.order_id is integer but references "order".id (bigserial). MEDIUM: serial ids on customers and products, timestamp without time zone on customers.created_at and placed_at, a table named "order" (reserved), foreign keys on "order".customer_id and order_items.order_id with no index. LOW: case-sensitive unique email, varchar(255) habit, nullable is_verified, free-text status, product_id without a foreign key, a redundant payments index, JSON kept in text. `examples/shop-schema-fixed.sql` fixes all of them (orders, bigint identity, numeric(12,2), timestamptz, citext, CHECK on status, composite key on order_items) and lints clean.
## Rules
- Explain each finding as the failure it causes (wrong totals, orphans, duplicate signups, an outage at 2.1 billion ids), not as a style rule.
- Keep the user's conventions (UUID ids, naming) unless they cause a real problem; say when a finding is a judgement call.
- Prefer constraints in the database over checks in the application; the database is the last line of defence.
- Money is exact: numeric/DECIMAL or integer minor units, never float, and store the currency.
- Time is an instant: timestamptz in PostgreSQL, UTC everywhere in MySQL.
- Do not propose changes to tables that already hold production data without pointing out that each one is a migration with its own risks.
FILE:references/design-rules.md
# Design rules and finding codes
| Code | Sev | What goes wrong | Acceptable when | Fix |
|---|---|---|---|---|
| NO_PRIMARY_KEY | HIGH | duplicate rows cannot be told apart or deleted one at a time; logical replication and CDC need a key; ORMs misbehave | never for OLTP tables (staging tables loaded and dropped in one job, maybe) | surrogate `id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY` or a composite natural key (order_id, product_id) |
| FLOAT_MONEY | HIGH | 0.1 + 0.2 = 0.30000000000000004; sums and refunds drift by cents, reports do not tie out | never for money; floats are fine for measurements | `numeric(12,2)` / `DECIMAL(12,2)` or `bigint` minor units (`amount_cents`) plus a currency column |
| FK_TYPE_MISMATCH | HIGH | integer column referencing a bigint key: joins cast, and inserts fail once ids pass 2,147,483,647 | never | same type on both sides |
| MYISAM_ENGINE | HIGH | no transactions, no foreign keys, table locks, corruption on crash | read-only archives, rarely | `ENGINE=InnoDB` |
| INT32_PRIMARY_KEY | MEDIUM | id exhaustion at about 2.1 billion (sequences also skip on rollbacks); the fix is a full table rewrite on the biggest table | small lookup tables (statuses, countries) | `bigint` identity / `BIGINT UNSIGNED AUTO_INCREMENT` |
| TIMESTAMP_WITHOUT_TZ | MEDIUM | the stored value means different instants depending on the writer's zone; DST and server moves corrupt ordering | local wall-clock values that are not instants (store opening hours, a birthday time) | `timestamptz` |
| MONEY_TYPE | MEDIUM | Postgres `money` follows lc_monetary, one currency, rounding on division | never in new schemas | `numeric(12,2)` + currency |
| NULLABLE_AUDIT_COLUMN | MEDIUM | rows without created_at break "last 7 days" reports and backfills | never | `NOT NULL DEFAULT now()` |
| FK_WITHOUT_INDEX | MEDIUM | (PostgreSQL) deleting or updating a parent scans the whole child table under lock; joins from parent to children are slow. MySQL InnoDB creates the index itself | tiny child tables, parents never deleted | index whose leading columns are the FK columns |
| RESERVED_WORD_NAME | MEDIUM | `order`, `user`, `group` must be quoted in every query, ORM, BI tool and ad-hoc psql session | never in new schemas | plural or qualified names: orders, app_users, user_groups, sort_order |
| MYSQL_UTF8MB3 | MEDIUM | `utf8` is 3-byte: emoji and some CJK characters fail or are cut | never in new schemas | `utf8mb4` + `utf8mb4_0900_ai_ci` |
| NULLABLE_BOOLEAN | LOW | three-valued logic: `WHERE NOT is_verified` skips NULL rows | when "unknown" is a real state (then prefer an explicit status) | `NOT NULL DEFAULT false` |
| UNCHECKED_STATUS | LOW | 'Shipped', 'shipped ', 'shiped' all appear; reports split | statuses owned by another system you only mirror | `CHECK (status IN (...))`, an enum type or a lookup table FK |
| MISSING_FOREIGN_KEY | LOW | orphans after deletes, typos in ids, no documented relation | cross-database or event-sourced references; document them | `REFERENCES parent(id)` + index |
| CASE_SENSITIVE_UNIQUE_EMAIL | LOW | Ana@x.com and ana@x.com are two accounts; password resets go to the wrong one | never for login emails | `citext` or `UNIQUE INDEX ON t (lower(email))` and normalize on write |
| VARCHAR_255_HABIT | LOW | in Postgres, 255 is an arbitrary MySQL-era limit; real rules are missing and odd values get rejected | when 255 really is the rule | `text` + `CHECK (char_length(x) <= n)` for real limits |
| REDUNDANT_INDEX | LOW | an index that is a prefix of another (or of the PK) costs writes and memory for nothing | the shorter index is much smaller and very hot | drop it |
| JSON_IN_TEXT | LOW | no validation, no operators, no GIN index | the text is not really JSON | `jsonb` / `JSON` |
| NO_CREATED_AT | LOW | debugging, audits and backfills need to know when rows appeared | pure join tables | `created_at timestamptz NOT NULL DEFAULT now()` |
| MIXED_CASE_NAME | LOW | quoted "OrderItems" must be quoted forever in Postgres | never | snake_case |
| DATETIME_NO_ZONE | LOW | (MySQL) DATETIME has no zone; mixed writers store mixed zones | when every connection sets time_zone='+00:00' | enforce UTC |
## Things the linter cannot judge (review by hand)
- **Keys**: is there a natural unique key besides the surrogate id? Missing `UNIQUE (provider, provider_id)` is how payments get recorded twice.
- **ON DELETE**: RESTRICT/NO ACTION by default; CASCADE only for true child rows (order_items of an order), never from customers to payments.
- **CHECK constraints for invariants**: `quantity > 0`, `ends_at > starts_at`, `discount <= total`.
- **Multi-tenancy**: tenant_id on every tenant table, first in unique keys and indexes, and in every FK (composite FKs prevent cross-tenant references).
- **Soft deletes**: partial unique indexes `WHERE deleted_at IS NULL`, or the email can never be reused.
- **Denormalized totals**: if orders.total is stored, say how it stays equal to the sum of order_items.
- **PII**: list columns with personal data; consider a separate table with stricter access and retention.
FILE:references/type-choices.md
# Type choices (PostgreSQL / MySQL 8)
| Data | PostgreSQL | MySQL 8 | Avoid |
|---|---|---|---|
| Surrogate id | `bigint GENERATED ALWAYS AS IDENTITY` | `BIGINT UNSIGNED AUTO_INCREMENT` | `serial`/`int` on growing tables |
| Public / distributed id | `uuid` (UUIDv7 for index locality, `gen_random_uuid()` for v4) | `BINARY(16)` with UUID_TO_BIN(x, 1) | `varchar(36)` UUIDs (3x bigger, slower) |
| Money | `numeric(12,2)` (or (19,4) for rates) + `currency char(3)` | `DECIMAL(12,2)` + `CHAR(3)` | `float`, `double`, `real`, Postgres `money` |
| Money as integer | `bigint amount_cents` (mind currencies with 0 or 3 decimals) | `BIGINT` | `int` cents (overflows at 21 million) |
| Instant (event happened) | `timestamptz` | `DATETIME(6)` in UTC, or `TIMESTAMP` (2038 limit, converted by session zone) | `timestamp` without time zone |
| Calendar date | `date` | `DATE` | timestamps at midnight |
| Wall-clock time / schedule | `time` + an IANA zone column | `TIME` + zone column | `timetz` |
| Duration | `interval` or integer seconds | `INT` seconds | text like "1h30" |
| Text | `text` (+ CHECK for real limits) | `VARCHAR(n)` with a real n; `TEXT` for long bodies | `varchar(255)` by habit; `char(n)` padding |
| Case-insensitive text (email, username) | `citext` or a `lower()` unique index | case-insensitive `_ci` collation (default) | plain unique on text in Postgres |
| Flag | `boolean NOT NULL DEFAULT false` | `BOOLEAN` (TINYINT(1)) `NOT NULL DEFAULT 0` | nullable booleans, 'Y'/'N' strings |
| Status | `text` + `CHECK (... IN (...))`, enum type, or lookup table | `ENUM(...)` or lookup table | unconstrained text |
| JSON | `jsonb` (GIN index for containment) | `JSON` (generated columns to index) | JSON in text |
| IP address | `inet` / `cidr` | `VARBINARY(16)` with INET6_ATON | text |
| Large binary | object storage + key in a `text` column | same | big BLOBs in hot tables |
## Notes
- Changing a column type later rewrites the table in both engines (with a few exceptions such as widening varchar in Postgres). Get ids and money right first.
- Enum types in Postgres: adding a value is cheap, removing or renaming one is not. Lookup tables are easier to evolve and can carry labels and sort order.
- `NOT NULL` with a sensible default is the norm; nullable should mean "genuinely unknown or not applicable".
- Store the currency next to every amount, even if today there is only one.
- Name things the same everywhere: `customer_id` in every table that references customers, `_at` for instants, `_on` or `_date` for dates, `is_`/`has_` for booleans.
FILE:templates/schema-review.md
# Schema review: <project / schema file>, <date>
**Engine:** <PostgreSQL 16> **Scope:** <5 tables: customers, products, orders, order_items, payments>
**Context:** <OLTP shop backend, ~2M orders/year, Prisma ORM, no data yet>
**Command:** `python3 scripts/schema_lint.py <shop-schema.sql>`
**Verdict:** <CHANGES NEEDED before the first release: 4 HIGH, 8 MEDIUM, 8 LOW>
## Must fix (HIGH)
| # | Table.column | Finding | What goes wrong | Fix |
|---|---|---|---|---|
| 1 | <products.price> | <FLOAT_MONEY> | <0.1 + 0.2 != 0.3; order totals drift by cents> | <numeric(12,2) NOT NULL CHECK (price >= 0)> |
## Should fix (MEDIUM)
| # | Table.column | Finding | What goes wrong | Fix |
|---|---|---|---|---|
| 1 | <"order"> | <RESERVED_WORD_NAME> | <must be quoted in every query and BI tool> | <rename to orders> |
## Consider (LOW / judgement calls)
- <customers.email: citext so Ana@x.com and ana@x.com are one account>
## Found by reading (not by the linter)
- <payments: add UNIQUE (provider, provider_id) so a webhook retry cannot record a payment twice>
- <ON DELETE: keep RESTRICT on payments -> orders; CASCADE only order_items -> orders>
## Corrected DDL
```sql
<full corrected schema, lints clean>
```
## Decisions for the team
- <bigint identity vs UUIDv7 for public ids>
- <store orders.total or compute it from order_items?>
## If tables already hold data
- <each change above is a migration; list them and run a migration safety review before deploying>
FILE:examples/shop-schema.sql
-- Small online shop schema (PostgreSQL), first draft for review.
-- The problems are deliberate: this is what the skill is meant to catch.
CREATE TABLE customers (
id serial PRIMARY KEY,
email varchar(255) NOT NULL UNIQUE,
full_name varchar(255),
phone varchar(255),
is_verified boolean,
created_at timestamp
);
CREATE TABLE products (
id serial PRIMARY KEY,
sku varchar(64) NOT NULL UNIQUE,
title text NOT NULL,
price double precision NOT NULL,
attributes text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE "order" (
id bigserial PRIMARY KEY,
customer_id integer NOT NULL REFERENCES customers (id),
status varchar(20) NOT NULL DEFAULT 'pending',
total numeric(12,2) NOT NULL,
placed_at timestamp NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES "order" (id),
product_id bigint NOT NULL,
quantity integer NOT NULL,
unit_price numeric(12,2) NOT NULL
);
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id integer NOT NULL REFERENCES "order" (id),
amount real NOT NULL,
provider text NOT NULL,
provider_id text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX payments_order_idx ON payments (order_id);
CREATE INDEX payments_order_created_idx ON payments (order_id, created_at);
FILE:examples/shop-schema-fixed.sql
-- Small online shop schema (PostgreSQL), after review.
-- Every change is explained in templates/schema-review.md style in the review.
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email citext NOT NULL UNIQUE,
full_name text,
phone text CHECK (char_length(phone) <= 32),
is_verified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE,
title text NOT NULL,
price numeric(12,2) NOT NULL CHECK (price >= 0),
attributes jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers (id),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled', 'refunded')),
total numeric(12,2) NOT NULL CHECK (total >= 0),
placed_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX orders_customer_idx ON orders (customer_id);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (id),
product_id bigint NOT NULL REFERENCES products (id),
quantity integer NOT NULL CHECK (quantity > 0),
unit_price numeric(12,2) NOT NULL CHECK (unit_price >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (order_id, product_id)
);
CREATE INDEX order_items_product_idx ON order_items (product_id);
CREATE TABLE payments (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders (id),
amount numeric(12,2) NOT NULL,
provider text NOT NULL,
provider_id text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, provider_id)
);
CREATE INDEX payments_order_created_idx ON payments (order_id, created_at);
FILE:scripts/schema_lint.py
#!/usr/bin/env python3
"""SQL schema design linter for PostgreSQL and MySQL DDL (Python 3 standard library only).
Reads CREATE TABLE / CREATE INDEX / ALTER TABLE ... ADD statements (a schema dump,
schema.sql, or the DDL of new tables in a pull request) and reports design problems
that are cheap to fix before data exists and expensive after: tables without a
primary key, money stored in floating point, foreign keys whose type differs from the
referenced key or that have no supporting index (PostgreSQL), *_id columns without a
foreign key, 32-bit primary keys, timestamps without time zone, nullable audit
columns and booleans, unchecked status columns, reserved-word and mixed-case names,
case-sensitive unique emails, redundant indexes, JSON kept in text columns, and MySQL
tables using MyISAM or 3-byte utf8.
It does not connect to a database and does not judge migration safety (locks,
backfills); it reviews the shape of the schema itself.
Usage:
python3 schema_lint.py FILE.sql [FILE.sql ...] [--dialect auto|postgres|mysql]
[--min-severity LOW|MEDIUM|HIGH] [--ignore CODE,CODE] [--json]
python3 schema_lint.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input (unreadable file,
no CREATE TABLE found).
"""
import argparse, json, re, sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
MONEY_RE = re.compile(r"(^|_)(price|amount|total|subtotal|cost|balance|fee|fees|tax|salary|refund|discount|paid|payment|revenue|credit|debit|charge)(_|$)", re.I)
RESERVED = {"user", "order", "group", "select", "table", "column", "desc", "asc", "check", "default", "from",
"where", "limit", "offset", "references", "primary", "key", "range", "rank", "window", "all", "both",
"end", "grant", "to", "type", "values", "constraint", "index", "match", "condition", "interval", "read", "row"}
RESERVED_PG_ONLY_SAFE = {"key", "range", "rank", "type", "values", "match", "condition", "interval", "read", "row", "index"} # fine unquoted in Postgres
RESERVED_MY_ONLY_SAFE = {"offset", "user", "end", "type", "values", "row"} # fine unquoted in MySQL 8
STATUS_RE = re.compile(r"^(status|state|kind|role|type|.*_status|.*_state|.*_type)$", re.I)
JSONISH_RE = re.compile(r"(^|_)(json|payload|metadata|meta|attributes|settings|extra|data)$", re.I)
INT32 = {"int", "integer", "int4", "serial", "serial4", "mediumint", "smallint", "int2", "smallserial", "tinyint"}
INT64 = {"bigint", "int8", "bigserial", "serial8"}
def finding(sev, code, table, column, problem, fix, line=0):
return {"severity": sev, "code": code, "table": table, "column": column, "line": line, "problem": problem, "fix": fix}
def strip_comments(sql):
out, i, n = [], 0, len(sql)
while i < n:
c = sql[i]
if c in "'\"`":
j = i + 1
while j < n and sql[j] != c:
j += 2 if sql[j] == "\\" and c == "'" else 1
out.append(sql[i:j + 1]); i = j + 1
elif sql.startswith("--", i) or c == "#" and (i == 0 or sql[i - 1] in "\n \t"):
j = sql.find("\n", i); j = n if j < 0 else j
out.append(" " * (j - i)); i = j
elif sql.startswith("/*", i):
j = sql.find("*/", i + 2); j = n if j < 0 else j + 2
out.append(re.sub(r"[^\n]", " ", sql[i:j])); i = j
else:
out.append(c); i += 1
return "".join(out)
def split_top(s, sep=","):
parts, depth, cur, q = [], 0, [], None
for ch in s:
if q:
cur.append(ch)
if ch == q:
q = None
continue
if ch in "'\"`":
q = ch
elif ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if ch == sep and depth == 0:
parts.append("".join(cur)); cur = []
else:
cur.append(ch)
if "".join(cur).strip():
parts.append("".join(cur))
return parts
def statements(sql):
clean = strip_comments(sql)
pos = 0
for part in split_top(clean, ";"):
line = clean.count("\n", 0, pos + len(part) - len(part.lstrip())) + 1
pos += len(part) + 1
if part.strip():
yield line, part.strip()
def ident(s):
s = s.strip()
quoted = s[:1] in "\"`"
name = s.split(".")[-1].strip().strip("\"`")
return name, quoted
def cols_list(s):
return [ident(re.sub(r"\s+(ASC|DESC|NULLS\s+(FIRST|LAST)).*$", "", c.strip(), flags=re.I))[0].lower() if not c.strip().startswith("(") and "(" not in c else c.strip().lower()
for c in split_top(s)]
class Schema:
def __init__(self):
self.tables, self.indexes, self.fks, self.enums = {}, [], [], set()
self.dialect_hints = {"mysql": 0, "postgres": 0}
def parse(sql):
sc = Schema()
if re.search(r"AUTO_INCREMENT|ENGINE\s*=|`|UNSIGNED|DATETIME|TINYINT\(", sql, re.I):
sc.dialect_hints["mysql"] += 1
if re.search(r"\bSERIAL\b|BIGSERIAL|TIMESTAMPTZ|JSONB|GENERATED\s+(ALWAYS|BY DEFAULT)\s+AS\s+IDENTITY|::|CREATE\s+TYPE|CITEXT|CONCURRENTLY|\bTEXT\[\]", sql, re.I):
sc.dialect_hints["postgres"] += 1
for line, st in statements(sql):
m = re.match(r"CREATE\s+(?:TEMP(?:ORARY)?\s+|UNLOGGED\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w\".`]+)\s*\((.*)\)\s*(.*)$", st, re.I | re.S)
if m:
name, quoted = ident(m.group(1))
t = {"name": name, "quoted": quoted, "line": line, "cols": {}, "order": [], "pk": [], "uniques": [],
"checks": " ".join([]), "options": m.group(3), "raw": st}
checks = []
for item in split_top(m.group(2)):
parse_item(item.strip(), t, sc, checks)
t["checks"] = " ".join(checks).lower()
sc.tables[name.lower()] = t
continue
m = re.match(r"CREATE\s+(UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([\w\".`]*)\s*ON\s+(?:ONLY\s+)?([\w\".`]+)\s*(?:USING\s+\w+\s*)?\((.*)\)\s*(.*)$", st, re.I | re.S)
if m:
sc.indexes.append({"table": ident(m.group(3))[0].lower(), "cols": cols_list(m.group(4)), "unique": bool(m.group(1)),
"name": ident(m.group(2))[0] if m.group(2) else "", "partial": "where" in m.group(5).lower(), "line": line})
continue
m = re.match(r"ALTER\s+TABLE\s+(?:ONLY\s+)?(?:IF\s+EXISTS\s+)?([\w\".`]+)\s+(.*)$", st, re.I | re.S)
if m:
tname = ident(m.group(1))[0].lower()
for action in split_top(m.group(2)):
a = action.strip()
am = re.match(r"ADD\s+(?:COLUMN\s+)(?:IF\s+NOT\s+EXISTS\s+)?(.*)$", a, re.I | re.S)
if am and tname in sc.tables:
parse_item(am.group(1), sc.tables[tname], sc, [])
continue
am = re.match(r"ADD\s+(.*)$", a, re.I | re.S)
if am and tname in sc.tables:
chk = []
parse_item(am.group(1), sc.tables[tname], sc, chk)
sc.tables[tname]["checks"] += " " + " ".join(chk).lower()
continue
m = re.match(r"CREATE\s+TYPE\s+([\w\".]+)\s+AS\s+ENUM", st, re.I)
if m:
sc.enums.add(ident(m.group(1))[0].lower())
m = re.match(r"CREATE\s+(?:DOMAIN|EXTENSION)\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w\".]+)", st, re.I)
if m:
sc.enums.add(ident(m.group(1))[0].lower())
return sc
def parse_item(item, t, sc, checks):
u = item.upper()
m = re.match(r"CONSTRAINT\s+[\w\"`]+\s+(.*)$", item, re.I | re.S)
if m:
item, u = m.group(1).strip(), m.group(1).strip().upper()
if u.startswith("PRIMARY KEY"):
t["pk"] = cols_list(re.search(r"\((.*)\)", item, re.S).group(1)); return
if re.match(r"UNIQUE\b", u):
cm = re.search(r"\((.*?)\)", item, re.S)
if cm:
cols = cols_list(cm.group(1))
t["uniques"].append(cols)
sc.indexes.append({"table": t["name"].lower(), "cols": cols, "unique": True, "name": "", "partial": False, "line": t["line"]})
return
if u.startswith("FOREIGN KEY"):
fm = re.match(r"FOREIGN\s+KEY\s*\((.*?)\)\s*REFERENCES\s+([\w\".`]+)\s*(?:\((.*?)\))?", item, re.I | re.S)
if fm:
sc.fks.append({"table": t["name"].lower(), "cols": cols_list(fm.group(1)), "ref": ident(fm.group(2))[0].lower(),
"ref_cols": cols_list(fm.group(3)) if fm.group(3) else None})
return
if u.startswith("CHECK"):
checks.append(item); return
if re.match(r"(UNIQUE\s+)?(KEY|INDEX)\b", u) or re.match(r"(FULLTEXT|SPATIAL)\b", u):
cm = re.search(r"\((.*)\)", item, re.S)
if cm:
sc.indexes.append({"table": t["name"].lower(), "cols": cols_list(cm.group(1)), "unique": u.startswith("UNIQUE"),
"name": "", "partial": False, "line": t["line"]})
return
if u.startswith(("EXCLUDE", "LIKE", "PERIOD")):
return
cm = re.match(r"([\"`][^\"`]+[\"`]|[\w$]+)\s+(.*)$", item, re.S)
if not cm:
return
name, quoted = ident(cm.group(1))
rest = cm.group(2)
tm = re.match(r"((?:double\s+precision|character\s+varying|bit\s+varying|timestamp(?:\s*\(\d+\))?(?:\s+with(?:out)?\s+time\s+zone)?(?!\w)|time(?:\s+with(?:out)?\s+time\s+zone)?(?!\w)|[\w.]+)(?:\s*\([^)]*\))?(?:\s+unsigned)?(?:\s*\[\])?)", rest, re.I)
ctype = re.sub(r"\s+", " ", tm.group(1).lower()) if tm else ""
ru = rest.upper()
col = {"name": name, "quoted": quoted, "type": ctype, "not_null": "NOT NULL" in ru or "PRIMARY KEY" in ru,
"default": "DEFAULT" in ru or "IDENTITY" in ru or "AUTO_INCREMENT" in ru or ctype.endswith("serial"),
"check": "CHECK" in ru}
t["cols"][name.lower()] = col
t["order"].append(name.lower())
if "PRIMARY KEY" in ru:
t["pk"] = [name.lower()]
if re.search(r"\bUNIQUE\b", ru):
t["uniques"].append([name.lower()])
sc.indexes.append({"table": t["name"].lower(), "cols": [name.lower()], "unique": True, "name": "", "partial": False, "line": t["line"]})
rm = re.search(r"REFERENCES\s+([\w\".`]+)\s*(?:\((.*?)\))?", rest, re.I)
if rm:
sc.fks.append({"table": t["name"].lower(), "cols": [name.lower()], "ref": ident(rm.group(1))[0].lower(),
"ref_cols": cols_list(rm.group(2)) if rm.group(2) else None})
if col["check"]:
checks.append(f"check ({name})")
def qi(name):
return f'"{name}"' if name.lower() in RESERVED or name != name.lower() else name
def base_type(ctype):
t = re.sub(r"\(.*?\)|\s+unsigned|\[\]", "", ctype).strip()
return {"int4": "integer", "int": "integer", "serial": "integer", "serial4": "integer", "int8": "bigint",
"bigserial": "bigint", "serial8": "bigint", "int2": "smallint", "smallserial": "smallint"}.get(t, t)
def lint(sql, dialect="auto", ignore=()):
sc = parse(sql)
if dialect == "auto":
dialect = "mysql" if sc.dialect_hints["mysql"] > sc.dialect_hints["postgres"] else "postgres"
out = []
def add(*a, **k):
f = finding(*a, **k)
if f["code"] not in ignore:
out.append(f)
table_names = set(sc.tables)
for key, t in sc.tables.items():
tn = t["name"]
ln = t["line"]
if not t["pk"]:
add("HIGH", "NO_PRIMARY_KEY", tn, "", "table has no primary key: duplicate rows cannot be told apart, updates and replication (logical replication, MySQL row-based, CDC) suffer",
"add a bigint identity (or UUID) primary key, or a composite key on the natural unique columns", line=ln)
elif len(t["pk"]) == 1 and t["pk"][0] in t["cols"]:
c = t["cols"][t["pk"][0]]
bt = base_type(c["type"])
if bt in {"integer", "smallint", "mediumint", "tinyint"} and not re.search(r"(^|_)(type|status|kind|country|currency|lookup)s?$", tn, re.I):
add("MEDIUM", "INT32_PRIMARY_KEY", tn, c["name"], f"primary key {c['name']} is {c['type']} (max about 2.1 billion, less for smaller types); running out means an emergency type change on the biggest table",
"use bigint GENERATED ALWAYS AS IDENTITY (Postgres) or BIGINT UNSIGNED AUTO_INCREMENT (MySQL)", line=ln)
if dialect == "postgres" and t["quoted"] and t["name"] != t["name"].lower():
add("LOW", "MIXED_CASE_NAME", tn, "", f'table "{tn}" is quoted mixed case: every query must quote it forever', "use snake_case lower-case names", line=ln)
if tn.lower() in RESERVED and not (dialect == "postgres" and tn.lower() in RESERVED_PG_ONLY_SAFE) and not (dialect == "mysql" and tn.lower() in RESERVED_MY_ONLY_SAFE):
add("MEDIUM", "RESERVED_WORD_NAME", tn, "", f"table name '{tn}' is a reserved word and must be quoted in every query and ORM", "rename (e.g. users, orders, user_groups)", line=ln)
if dialect == "mysql":
opts = t["options"].lower()
if re.search(r"engine\s*=\s*myisam", opts):
add("HIGH", "MYISAM_ENGINE", tn, "", "ENGINE=MyISAM has no transactions, no foreign keys and table-level locks; crashes can corrupt it", "use ENGINE=InnoDB", line=ln)
if re.search(r"charset\s*=\s*utf8(mb3)?\b(?!mb4)", opts):
add("MEDIUM", "MYSQL_UTF8MB3", tn, "", "utf8 in MySQL is 3-byte utf8mb3: emoji and some CJK characters fail to insert or get mangled", "use DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci", line=ln)
if not any(re.match(r"^(created_at|created|created_on|inserted_at|date_created|creation_date)$", c) for c in t["cols"]):
add("LOW", "NO_CREATED_AT", tn, "", "no created_at column: hard to debug, audit or backfill later", "add created_at timestamptz NOT NULL DEFAULT now()", line=ln)
v255 = [c["name"] for c in t["cols"].values() if re.match(r"(varchar|character varying)\s*\(255\)", c["type"])]
if dialect == "postgres" and len(v255) >= 3:
add("LOW", "VARCHAR_255_HABIT", tn, ", ".join(v255), f"{len(v255)} columns are varchar(255): in Postgres the limit costs nothing to drop but 255 is rarely the real rule",
"use text, with a CHECK (char_length(x) <= n) where a real limit exists", line=ln)
for cname in t["order"]:
c = t["cols"][cname]
ct, bt = c["type"], base_type(c["type"])
cn = c["name"]
if MONEY_RE.search(cn) and bt in {"float", "real", "double", "double precision", "float4", "float8"}:
add("HIGH", "FLOAT_MONEY", tn, cn, f"{cn} is {ct}: binary floating point cannot store 0.10 exactly, sums drift by cents",
"use numeric(12,2) / DECIMAL(12,2), or bigint minor units (amount_cents)", line=ln)
if bt == "money" and dialect == "postgres":
add("MEDIUM", "MONEY_TYPE", tn, cn, "Postgres money type depends on lc_monetary, has one fixed currency and rounds", "use numeric(12,2) plus a currency column", line=ln)
if dialect == "postgres" and (re.match(r"^timestamp(\s*\(\d+\))?$", ct) or "without time zone" in ct):
add("MEDIUM", "TIMESTAMP_WITHOUT_TZ", tn, cn, f"{cn} is timestamp without time zone: the instant depends on whatever zone the writer assumed", "use timestamptz (stores an absolute instant, displays in the session zone)", line=ln)
if dialect == "mysql" and bt == "datetime" and re.search(r"_at$", cn):
add("LOW", "DATETIME_NO_ZONE", tn, cn, f"{cn} is DATETIME (no zone): make sure every writer stores UTC", "document and enforce UTC (time_zone='+00:00' on connections) or use TIMESTAMP within its 2038 range", line=ln)
if re.match(r"^(created_at|updated_at|inserted_at)$", cn, re.I) and not c["not_null"]:
add("MEDIUM", "NULLABLE_AUDIT_COLUMN", tn, cn, f"{cn} is nullable{'' if c['default'] else ' and has no default'}: rows without it will appear", f"{cn} timestamptz NOT NULL DEFAULT now()", line=ln)
if bt in {"boolean", "bool"} and not c["not_null"]:
add("LOW", "NULLABLE_BOOLEAN", tn, cn, f"{cn} is a nullable boolean: three states (true, false, unknown) and NULL-unsafe WHERE clauses", f"{cn} boolean NOT NULL DEFAULT false", line=ln)
if STATUS_RE.match(cn) and re.match(r"^(text|varchar|character varying|char|character)", bt) and not c["check"] \
and cn.lower() not in t["checks"] and not any(f["table"] == key and cname in f["cols"] for f in sc.fks):
add("LOW", "UNCHECKED_STATUS", tn, cn, f"{cn} is free text: typos like 'Shipped ' and 'shiped' will end up in the data", "add CHECK (status IN (...)), an enum type, or a FK to a lookup table", line=ln)
if bt in {"text", "longtext", "mediumtext"} and JSONISH_RE.search(cn):
add("LOW", "JSON_IN_TEXT", tn, cn, f"{cn} looks like JSON stored as {ct}: no validation, no indexing", "use jsonb (Postgres) or JSON (MySQL) if it really is JSON", line=ln)
if cn.lower() in RESERVED and not (dialect == "postgres" and cn.lower() in RESERVED_PG_ONLY_SAFE) and not (dialect == "mysql" and cn.lower() in RESERVED_MY_ONLY_SAFE) and cn.lower() not in {"type"}:
add("MEDIUM", "RESERVED_WORD_NAME", tn, cn, f"column name '{cn}' is a reserved word and needs quoting everywhere", "rename it (e.g. sort_order, user_id, group_name, ends_at)", line=ln)
if dialect == "postgres" and c["quoted"] and cn != cn.lower():
add("LOW", "MIXED_CASE_NAME", tn, cn, f'column "{cn}" is quoted mixed case: every query must quote it', "use snake_case", line=ln)
if cn.lower().endswith("_id") and cname not in t["pk"] and not any(f["table"] == key and cname in f["cols"] for f in sc.fks):
stem = cn.lower()[:-3]
cands = [x for x in (stem, stem + "s", stem + "es", stem[:-1] + "ies" if stem.endswith("y") else None) if x and x in table_names]
if cands:
add("LOW", "MISSING_FOREIGN_KEY", tn, cn, f"{cn} looks like a reference to {cands[0]} but has no foreign key: orphans and typos are possible", f"add REFERENCES {cands[0]}(id) (with an index)", line=ln)
if re.search(r"(^|_)email$", cn, re.I) and [cname] in t["uniques"] and dialect == "postgres" and bt != "citext":
if not any(i["table"] == key and any("lower(" in c2 for c2 in i["cols"]) for i in sc.indexes):
add("LOW", "CASE_SENSITIVE_UNIQUE_EMAIL", tn, cn, "UNIQUE on email is case-sensitive in Postgres: Ana@x.com and ana@x.com can both sign up", "CREATE UNIQUE INDEX ... ON t (lower(email)) or use citext", line=ln)
# foreign keys: types and indexes
for f in sc.fks:
t = sc.tables.get(f["table"])
r = sc.tables.get(f["ref"])
if not t:
continue
ref_cols = f["ref_cols"] or (r["pk"] if r else None)
if r and ref_cols and len(ref_cols) == len(f["cols"]):
for c, rc in zip(f["cols"], ref_cols):
a, b = t["cols"].get(c), r["cols"].get(rc)
if a and b and base_type(a["type"]) != base_type(b["type"]) and not ({base_type(a["type"]), base_type(b["type"])} <= {"uuid"}):
add("HIGH", "FK_TYPE_MISMATCH", t["name"], a["name"], f"{a['name']} is {a['type']} but references {r['name']}.{b['name']} ({b['type']}): joins need casts and the FK fails once ids pass the smaller range",
f"make {a['name']} the same type as {r['name']}.{b['name']}", line=t["line"])
if dialect == "postgres":
covered = any(idx["table"] == f["table"] and not idx["partial"] and idx["cols"][:len(f["cols"])] == f["cols"] for idx in sc.indexes) \
or t["pk"][:len(f["cols"])] == f["cols"] or any(u[:len(f["cols"])] == f["cols"] for u in t["uniques"])
if not covered:
add("MEDIUM", "FK_WITHOUT_INDEX", t["name"], ", ".join(f["cols"]), f"foreign key ({', '.join(f['cols'])}) -> {f['ref']} has no index starting with it: deletes/updates on {f['ref']} scan {t['name']}, and joins are slow",
f"CREATE INDEX CONCURRENTLY ON {qi(t['name'])} ({', '.join(f['cols'])})", line=t["line"])
# redundant indexes
for i, a in enumerate(sc.indexes):
t = sc.tables.get(a["table"])
if not t or a["partial"] or any("(" in c for c in a["cols"]):
continue
others = [b for j, b in enumerate(sc.indexes) if j != i and b["table"] == a["table"] and not b["partial"]]
if t["pk"] and a["cols"] == t["pk"][:len(a["cols"])] and not a["unique"]:
add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"index on ({', '.join(a['cols'])}) duplicates the primary key's leading columns", "drop it", line=a["line"])
continue
for b in others:
if len(b["cols"]) > len(a["cols"]) and b["cols"][:len(a["cols"])] == a["cols"] and not a["unique"]:
add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"index on ({', '.join(a['cols'])}) is a prefix of the index on ({', '.join(b['cols'])})", "drop the shorter one unless it is much smaller and hot", line=a["line"])
break
if b["cols"] == a["cols"] and j_before(sc.indexes, b, a) and a["unique"] == b["unique"]:
add("LOW", "REDUNDANT_INDEX", t["name"], ", ".join(a["cols"]), f"two indexes on ({', '.join(a['cols'])})", "drop one", line=a["line"])
break
out.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["table"], f["column"], f["code"]))
return dialect, sc, out
def j_before(lst, b, a):
return lst.index(b) < lst.index(a)
def report(path, dialect, sc, found):
counts = {s: sum(1 for f in found if f["severity"] == s) for s in SEV_ORDER}
out = [f"# Schema review: {path} ({dialect})", "",
f"Tables: {len(sc.tables)} Indexes: {len(sc.indexes)} Foreign keys: {len(sc.fks)} Findings: {len(found)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']})", ""]
if not found:
out.append("No findings.")
return "\n".join(out)
out += ["| Sev | Code | Table.Column | Problem | Fix |", "|---|---|---|---|---|"]
for f in found:
loc = f["table"] + ("." + f["column"] if f["column"] else "")
out.append(f"| {f['severity']} | {f['code']} | {loc} | {f['problem'].replace('|', '/')} | {f['fix'].replace('|', '/')} |")
return "\n".join(out)
def self_test():
ok_pk = "CREATE TABLE t (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now()"
cases = [
("clean postgres table", ok_pk + ", name text NOT NULL);", "postgres", [], ["NO_PRIMARY_KEY", "INT32_PRIMARY_KEY", "NO_CREATED_AT"]),
("no primary key", "CREATE TABLE logs (msg text, created_at timestamptz NOT NULL);", "auto", ["NO_PRIMARY_KEY"], []),
("table-level composite pk", "CREATE TABLE m (a bigint, b bigint, created_at timestamptz NOT NULL, PRIMARY KEY (a, b));", "auto", [], ["NO_PRIMARY_KEY"]),
("named pk constraint", "CREATE TABLE m (id bigint, created_at timestamptz NOT NULL, CONSTRAINT m_pk PRIMARY KEY (id));", "auto", [], ["NO_PRIMARY_KEY"]),
("pk added by alter", "CREATE TABLE m (id bigint NOT NULL, created_at timestamptz NOT NULL); ALTER TABLE m ADD CONSTRAINT m_pkey PRIMARY KEY (id);", "auto", [], ["NO_PRIMARY_KEY"]),
("serial pk is int32", "CREATE TABLE orders (id serial PRIMARY KEY, created_at timestamptz NOT NULL);", "auto", ["INT32_PRIMARY_KEY"], []),
("small lookup table int pk ok", "CREATE TABLE order_status (id smallint PRIMARY KEY, created_at timestamptz NOT NULL);", "auto", [], ["INT32_PRIMARY_KEY"]),
("float money", ok_pk + ", total_price double precision);", "postgres", ["FLOAT_MONEY"], []),
("numeric money ok", ok_pk + ", amount numeric(12,2) NOT NULL);", "postgres", [], ["FLOAT_MONEY"]),
("float non-money ok", ok_pk + ", latitude double precision);", "postgres", [], ["FLOAT_MONEY"]),
("money type", ok_pk + ", balance money);", "postgres", ["MONEY_TYPE"], []),
("timestamp without tz", ok_pk + ", shipped_at timestamp);", "postgres", ["TIMESTAMP_WITHOUT_TZ"], []),
("timestamp(3) without time zone", ok_pk + ", shipped_at timestamp(3) without time zone);", "postgres", ["TIMESTAMP_WITHOUT_TZ"], []),
("timestamptz ok", ok_pk + ", shipped_at timestamp with time zone);", "postgres", [], ["TIMESTAMP_WITHOUT_TZ"]),
("nullable created_at", "CREATE TABLE t (id bigint PRIMARY KEY, created_at timestamptz);", "postgres", ["NULLABLE_AUDIT_COLUMN"], []),
("no created_at", "CREATE TABLE t (id bigint PRIMARY KEY, name text);", "postgres", ["NO_CREATED_AT"], []),
("nullable boolean", ok_pk + ", is_active boolean);", "postgres", ["NULLABLE_BOOLEAN"], []),
("boolean not null ok", ok_pk + ", is_active boolean NOT NULL DEFAULT true);", "postgres", [], ["NULLABLE_BOOLEAN"]),
("unchecked status", ok_pk + ", status varchar(20) NOT NULL);", "postgres", ["UNCHECKED_STATUS"], []),
("status with check ok", ok_pk + ", status text NOT NULL CHECK (status IN ('a','b')));", "postgres", [], ["UNCHECKED_STATUS"]),
("status table check ok", ok_pk + ", status text NOT NULL, CONSTRAINT st CHECK (status IN ('a')));", "postgres", [], ["UNCHECKED_STATUS"]),
("reserved table name", "CREATE TABLE \"user\" (id bigint PRIMARY KEY, created_at timestamptz NOT NULL);", "postgres", ["RESERVED_WORD_NAME"], []),
("reserved column order", ok_pk + ", \"order\" int);", "postgres", ["RESERVED_WORD_NAME"], []),
("mixed case quoted", "CREATE TABLE \"OrderItems\" (id bigint PRIMARY KEY, created_at timestamptz NOT NULL);", "postgres", ["MIXED_CASE_NAME"], []),
("fk without index", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id bigint NOT NULL REFERENCES a(id));", "postgres", ["FK_WITHOUT_INDEX"], ["FK_TYPE_MISMATCH"]),
("fk with index ok", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id bigint REFERENCES a); CREATE INDEX ON b (a_id, id);", "postgres", [], ["FK_WITHOUT_INDEX"]),
("fk covered by composite pk", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE ab (a_id bigint REFERENCES a(id), x bigint, created_at timestamptz NOT NULL, PRIMARY KEY (a_id, x));", "postgres", [], ["FK_WITHOUT_INDEX"]),
("fk type mismatch", "CREATE TABLE a (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE b (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, a_id integer, FOREIGN KEY (a_id) REFERENCES a (id));", "postgres", ["FK_TYPE_MISMATCH"], []),
("missing fk on _id", "CREATE TABLE customers (id bigint PRIMARY KEY, created_at timestamptz NOT NULL); CREATE TABLE orders (id bigint PRIMARY KEY, created_at timestamptz NOT NULL, customer_id bigint NOT NULL);", "postgres", ["MISSING_FOREIGN_KEY"], []),
("external _id without table ok", ok_pk + ", stripe_id text);", "postgres", [], ["MISSING_FOREIGN_KEY"]),
("case-sensitive unique email", ok_pk + ", email text NOT NULL UNIQUE);", "postgres", ["CASE_SENSITIVE_UNIQUE_EMAIL"], []),
("lower(email) index ok", ok_pk + ", email text NOT NULL UNIQUE); CREATE UNIQUE INDEX t_email ON t (lower(email));", "postgres", [], ["CASE_SENSITIVE_UNIQUE_EMAIL"]),
("redundant prefix index", ok_pk + ", a bigint, b bigint); CREATE INDEX i1 ON t (a); CREATE INDEX i2 ON t (a, b);", "postgres", ["REDUNDANT_INDEX"], []),
("json in text", ok_pk + ", payload text);", "postgres", ["JSON_IN_TEXT"], []),
("varchar 255 habit", ok_pk + ", a varchar(255), b varchar(255), c varchar(255));", "postgres", ["VARCHAR_255_HABIT"], []),
("mysql myisam and utf8", "CREATE TABLE `t` (`id` int unsigned NOT NULL AUTO_INCREMENT, `created_at` datetime NOT NULL, PRIMARY KEY (`id`)) ENGINE=MyISAM DEFAULT CHARSET=utf8;", "auto", ["MYISAM_ENGINE", "MYSQL_UTF8MB3", "INT32_PRIMARY_KEY", "DATETIME_NO_ZONE"], ["TIMESTAMP_WITHOUT_TZ"]),
("mysql utf8mb4 innodb ok", "CREATE TABLE `t` (`id` bigint unsigned NOT NULL AUTO_INCREMENT, `created_at` datetime NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;", "auto", [], ["MYISAM_ENGINE", "MYSQL_UTF8MB3", "INT32_PRIMARY_KEY"]),
("mysql fk needs no extra index", "CREATE TABLE a (id bigint PRIMARY KEY, created_at datetime NOT NULL) ENGINE=InnoDB; CREATE TABLE b (id bigint PRIMARY KEY, created_at datetime NOT NULL, a_id bigint, FOREIGN KEY (a_id) REFERENCES a(id)) ENGINE=InnoDB;", "mysql", [], ["FK_WITHOUT_INDEX"]),
("comments and strings ignored", "-- CREATE TABLE x (a int);\n/* CREATE TABLE y (b int); */\n" + ok_pk + ", note text DEFAULT 'a;b -- c');", "postgres", [], ["NO_PRIMARY_KEY"]),
("alter add column parsed", ok_pk + "); ALTER TABLE t ADD COLUMN total_amount float;", "postgres", ["FLOAT_MONEY"], []),
]
passed = 0
for name, sql, dialect, expect, absent in cases:
d, sc, found = lint(sql, dialect)
codes = {f["code"] for f in found}
ok = all(c in codes for c in expect) and not any(c in codes for c in absent)
if name == "clean postgres table":
ok = ok and not found
if name == "comments and strings ignored":
ok = ok and len(sc.tables) == 1
if name.startswith("mysql myisam"):
ok = ok and d == "mysql"
passed += ok
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> got {sorted(codes)}"))
d, sc, found = lint("", "auto")
ok = report("x.sql", d, sc, found).endswith("No findings.")
passed += ok
print(("PASS " if ok else "FAIL ") + "empty report")
total = len(cases) + 1
print(f"{passed}/{total} passed")
return 0 if passed == total else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="SQL schema design linter (Postgres/MySQL DDL)")
ap.add_argument("files", nargs="*")
ap.add_argument("--dialect", default="auto", choices=["auto", "postgres", "mysql"])
ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
ap.add_argument("--ignore", default="")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.files:
ap.print_usage(sys.stderr)
return 2
ignore = {c.strip() for c in a.ignore.split(",") if c.strip()}
sql_all = []
for p in a.files:
try:
with open(p, encoding="utf-8") as fh:
sql_all.append(fh.read())
except OSError as e:
print(f"error: cannot read {p}: {e}", file=sys.stderr)
return 2
dialect, sc, found = lint("\n;\n".join(sql_all), a.dialect, ignore)
if not sc.tables:
print("error: no CREATE TABLE statements found", file=sys.stderr)
return 2
found = [f for f in found if SEV_ORDER[f["severity"]] <= SEV_ORDER[a.min_severity]]
if a.json:
print(json.dumps({"dialect": dialect, "tables": len(sc.tables), "findings": found}, indent=2))
else:
print(report(", ".join(a.files), dialect, sc, found))
return 1 if any(f["severity"] == "HIGH" for f in found) else 0
if __name__ == "__main__":
sys.exit(main())Source: github.com/f/prompts.chat · Contributor: f
---
name: markdown-link-checker
description: Checks a Markdown docs folder for links that break readers - relative links to missing or renamed files, anchors that match no heading, duplicate heading anchors, file-name case that only works on macOS, local absolute paths, reference-style links with missing, unused or duplicate definitions, images without alt text and bare URLs. Works offline and lists external URLs to check separately. Includes a stdlib Python checker with --self-test.
---
# Markdown Link Checker
You help maintainers, technical writers and developers keep documentation navigable. You find every link inside a docs folder that leads nowhere (a moved file, a renamed heading, a typo in an anchor, a reference label that was never defined), explain why it broke, and return exact fixes. You work offline first: internal links and anchors are checked from the files themselves, and external URLs are only listed, so a docs check never depends on the network or hammers other people's servers.
## Files in this skill
- `scripts/check_md_links.py` - walks a folder of .md/.markdown/.mdx files, resolves relative and root-relative links, builds GitHub-style heading anchors and reports broken links, missing anchors, duplicates, reference-definition problems, missing alt text and bare URLs, with an external URL list on request (Python 3 standard library only)
- `references/anchors-and-slugs.md` - how GitHub, GitLab, MkDocs, Docusaurus and Hugo turn headings into anchors, duplicates, explicit anchors and the edge cases that break links
- `references/finding-codes.md` - every finding code, its severity, why it matters and the usual fix, plus how to check external URLs politely
- `templates/link-report.md` - the report to hand back
- `examples/README.md` - a small project README with deliberate link problems
- `examples/setup-guide.md` - the guide it links to, with more problems (wrong-case file name, anchor case, missing reference, local path)
## Workflow
### 1. Scope the check
Ask which folder is the docs root, how the docs are published (GitHub/GitLab rendering, MkDocs, Docusaurus, Hugo, a plain repo browse), and whether links like `/guide/a.md` mean "from the repo root" or "from the site root". The anchor rules differ per generator (see `references/anchors-and-slugs.md`); default to GitHub rules and say so.
### 2. Run the checker
```
python3 scripts/check_md_links.py examples --external
python3 scripts/check_md_links.py docs README.md --root . --min-severity MEDIUM
python3 scripts/check_md_links.py docs --ignore BARE_URL,UNUSED_REF_DEF --json > links.json
python3 scripts/check_md_links.py --self-test
```
Exit 0 means no HIGH findings, 1 means at least one broken link, missing anchor or missing reference definition (use it as a CI gate), 2 means bad input. Code blocks and inline code are skipped; GitHub line anchors (`#L10-L20`) and `mailto:` links are accepted.
### 3. Triage and fix
Work HIGH first. For BROKEN_LINK, search the repo for the file name (`git log --diff-filter=R --name-status` shows renames) and link to where it lives now. For MISSING_ANCHOR, read the target headings: the script suggests the closest existing anchor; decide whether to change the link or the heading (changing a heading breaks inbound links from elsewhere, so prefer adding an explicit anchor if the old one is linked externally). For DUPLICATE_ANCHOR, make the headings unique. For CASE_MISMATCH, use the exact file name. Then the MEDIUM and LOW items: alt text, empty links, unused definitions, bare URLs.
### 4. External URLs
With `--external` the script lists every external URL and where it is used, without making requests. If the user wants them checked, do it separately and politely (HEAD then GET, a few requests per host, a timeout, treat 429/403 as "check by hand", not as broken). See `references/finding-codes.md`.
### 5. Deliver
Fill `templates/link-report.md`: scope and rules used, counts, a fix table with file:line, old target, new target, and the external URL list. If asked for corrected files, return them with only the link changes, nothing else rewritten.
## Example result
`examples/` (2 files): 13 findings, 6 HIGH. README.md links `setup-guide.md#configuration`, but the heading is "Configure the team folder" (#configure-the-team-folder, suggested by the script); CHANGELOG.md, ../CONTRIBUTING.md, docs/style.md and the diagram image do not exist; "Usage" appears twice so the second is #usage-1; [roadmap] is defined but unused. setup-guide.md links `readme.md` (only README.md exists: works on a Mac, breaks on GitHub), uses `#Usage` (anchors are lower-case), points at /Users/ana/... and uses [support] without a definition. 4 external URLs listed, none fetched.
## Rules
- Never fetch external URLs unless the user asks; when you do, rate-limit and do not report 403/429 as broken.
- Fix the link, not the content: do not rewrite prose while fixing paths.
- Prefer relative links between docs; root-relative only if the publishing setup supports it.
- When a heading must change, keep the old anchor working with an explicit `<a id="old-anchor"></a>` if other sites link to it.
- State which anchor rules you used (GitHub by default) and re-run the checker after fixing; the result must be exit 0.
- Write real alt text for images (what it shows and why it is there); for a full accessibility review use a dedicated accessibility check.
FILE:references/anchors-and-slugs.md
# Heading anchors and slugs
A link like `guide.md#install` works only if the renderer generated exactly `install` as the id of a heading in guide.md. Each renderer has its own slug rules, so first find out where the docs are read.
## GitHub (default used by the script)
1. Take the heading text as rendered: inline markup removed (`code` keeps its text, `**bold**` becomes bold, `[text](url)` becomes text, HTML tags dropped).
2. Lower-case it.
3. Remove every character that is not a letter, digit, space, hyphen or underscore (letters include accented and non-Latin letters; emoji and punctuation go).
4. Turn each space into a hyphen. Spaces are NOT collapsed: "C++ & Rust" becomes `c--rust`.
5. Duplicates get `-1`, `-2`, ... in order of appearance: the second "Usage" is `#usage-1`.
| Heading | Anchor |
|---|---|
| `## What's new? (v2.0)` | `#whats-new-v20` |
| ``## The `--dry-run` flag`` | `#the---dry-run-flag` |
| `## Über uns` | `#über-uns` (GitHub also accepts the percent-encoded form) |
| `## C++ & Rust: 100% safe?` | `#c--rust-100-safe` |
| `## snake_case_name` | `#snake_case_name` |
| `## Usage` (second time) | `#usage-1` |
The script's slug function matches the github-slugger library on these cases.
## Other renderers
- **GitLab**: like GitHub, but consecutive hyphens are collapsed into one (`#c-rust-100-safe`), so links copied between GitHub and GitLab can break.
- **MkDocs (Python-Markdown toc)**: drops non-ASCII by default, collapses separators; `{#custom-id}` is available with attr_list. Check `markdown_extensions: toc: slugify` in mkdocs.yml.
- **Docusaurus**: GitHub-like slugs; supports `## Heading {#custom-id}`.
- **Hugo (goldmark)**: GitHub-like by default; `autoHeadingIDType` can be `github`, `github-ascii` or `blackfriday`.
- **VS Code preview / most IDEs**: GitHub-like.
If the published site and the repo view disagree, prefer explicit anchors for anything linked from outside.
## Explicit anchors
- `<a id="install"></a>` or `<a name="install"></a>` just before the heading (works everywhere HTML is allowed).
- `## Install {#install}` where the renderer supports attribute lists (MkDocs attr_list, Docusaurus, Hugo, Pandoc). On GitHub the `{#...}` shows up as literal text.
## What breaks anchors
- Renaming a heading ("Configuration" -> "Configure the team folder").
- Duplicates: links to `#usage` always land on the first one.
- Capital letters in the link (`#Usage`): GitHub anchors are lower-case; some browsers forgive it, many renderers do not.
- Copying the anchor from another renderer (GitLab vs GitHub hyphens).
- Headings generated by includes or templates the checker cannot see; mark these with `--ignore MISSING_ANCHOR` for that folder only, and say so.
- Line anchors (`#L10`, `#L10-L20`) are GitHub file-view features; they are accepted but break if the file moves or lines shift.
FILE:references/finding-codes.md
# Finding codes
| Code | Severity | Why it matters | Usual fix |
|---|---|---|---|
| BROKEN_LINK | HIGH | relative link to a file or folder that does not exist (moved, renamed, typo) | link to the current path; `git log --diff-filter=R --name-status -- docs/` shows renames |
| BROKEN_IMAGE | HIGH | image path does not exist; readers see a broken icon | fix the path or commit the image |
| MISSING_ANCHOR | HIGH | #fragment matches no heading or explicit anchor in the target file | use the suggested anchor, or add an explicit anchor |
| MISSING_REF_DEF | HIGH | `[text][label]` has no `[label]: url` definition; it renders as plain bracketed text | add the definition or fix the label (labels are case-insensitive) |
| DUPLICATE_ANCHOR | MEDIUM | two headings produce the same slug; the second gets `-1` and links land on the first | make headings unique, e.g. "Usage (CLI)" and "Usage (API)" |
| DUPLICATE_REF_DEF | MEDIUM | the same label is defined twice; only the first counts | delete or rename one |
| CASE_MISMATCH | MEDIUM | file exists only with different case; fine on macOS/Windows, broken on Linux, GitHub Pages and most CI | use the exact name |
| ANCHOR_CASE | MEDIUM | anchor only matches when lower-cased | write anchors in lower case |
| LOCAL_ABSOLUTE_PATH | MEDIUM | link to /Users/..., /home/..., C:\ or file:// works only on the author's machine | relative path, or remove |
| EMPTY_LINK | MEDIUM | `[text]()` or empty href | fill in or remove |
| EMPTY_LINK_TEXT | MEDIUM | `[](url)`: nothing to click or read aloud | add link text |
| MISSING_ALT | MEDIUM | image without alt text; screen readers say "image" or the file name | describe what it shows and why it is there |
| UNUSED_REF_DEF | LOW | definition never used; usually left over after an edit | delete, or link to it if the link was lost |
| BARE_URL | LOW | raw URL in text; not clickable in some renderers, read character by character | `[descriptive text](url)` or `<url>` |
| OUTSIDE_ROOT | LOW | relative link leaves the docs root; breaks if docs are published alone | link to the published URL |
Exit codes: 0 = no HIGH, 1 = HIGH present, 2 = bad input. Use `--min-severity` and `--ignore` to tune a CI gate, and record any ignore in the repo with a reason.
## Link forms the script understands
- Inline: `[text](path.md#anchor "title")`, `[text](<path with spaces.md>)`, `[text](my%20notes.md)`
- Images: ``, `<img src="img.png" alt="...">`
- Reference: `[text][label]`, `[label][]`, `[label]` with `[label]: url "title"` anywhere in the same file
- Autolinks `<https://...>`, HTML `<a href="...">`, explicit anchors `<a id>`, `<a name>`, `{#id}`
- Root-relative `/guide/a.md` resolves from `--root` (default: the first folder argument)
- Skipped: fenced and indented code blocks, inline code, `mailto:`/`tel:`, GitHub line anchors, task list boxes `[ ]` `[x]`
## Checking external URLs (only when asked)
- Deduplicate first: `--external` lists each URL once with every place it is used.
- Use HEAD, fall back to GET when the server rejects HEAD (405) or lies about it.
- One or two requests per host at a time, a 10 s timeout, a clear User-Agent.
- 200-299 OK; 301/308 update the link to the new location; 404/410 broken; 403, 429 and timeouts are "check by hand" (bot protection, rate limits).
- Anchors on external pages cannot be checked reliably offline; note them, do not guess.
- Prefer links to stable, versioned pages (tagged docs, permalinks) over "latest".
FILE:templates/link-report.md
# Docs link check: <repo or folder>, <date>
**Scope:** <docs/ and README.md, 42 files> **Anchor rules:** <GitHub> **Root for /links:** <repo root>
**Command:** `python3 scripts/check_md_links.py <docs README.md --root . --external>`
**Result:** <13 findings: HIGH 6, MEDIUM 5, LOW 2 -> after fixes: exit 0>
## Must fix (HIGH)
| # | File:Line | Code | Link as written | Problem | Fix (new target) |
|---|---|---|---|---|---|
| 1 | <README.md:11> | <MISSING_ANCHOR> | <setup-guide.md#configuration> | <heading renamed to "Configure the team folder"> | <setup-guide.md#configure-the-team-folder> |
## Should fix (MEDIUM)
| # | File:Line | Code | Problem | Fix |
|---|---|---|---|---|
| 1 | <setup-guide.md:25> | <CASE_MISMATCH> | <readme.md vs README.md> | <README.md#contents> |
## Clean-up (LOW)
- <unused [roadmap] definition in README.md:42 - delete or link it>
- <bare URL README.md:21 -> [Tidepool documentation](https://docs.example.com/tidepool)>
## Images and alt text
| File:Line | Image | Alt text to use |
|---|---|---|
| <README.md:5> | <images/sync-diagram.png (missing)> | <"Diagram: phone app sends notes to the sync server, which writes them to the team folder"> |
## External URLs (not fetched)
| URL | Used at | Status (if checked) |
|---|---|---|
| <https://docs.example.com/tidepool> | <README.md:21> | <not checked> |
## Prevent it next time
- <run the checker in CI on every docs change (exit 1 fails the build)>
- <when renaming a heading that others link to, keep an explicit anchor with the old id>
FILE:examples/README.md
# Tidepool
Tidepool syncs field notes from a phone to a shared team folder.

## Contents
- [Quick start](#quick-start)
- [Install](setup-guide.md#install)
- [Configuration](setup-guide.md#configuration)
- [Troubleshooting](setup-guide.md#troubleshooting)
- [FAQ](#faq)
## Quick start
1. Install the app (see [the setup guide][setup]).
2. Sign in and pick a team folder.
3. Read the [release notes](CHANGELOG.md) before upgrading.
Full documentation lives at https://docs.example.com/tidepool and the API
reference is at <https://api.example.com/tidepool/v2>.
## Usage
Run `tidepool sync` to push new notes. Links inside code are not checked:
```
[not a link](missing.md)
```
## Usage
See [Contributing](../CONTRIBUTING.md) and the [style guide][style].
## Faq
**Can I sync offline?** Notes queue until the phone is online again.
[setup]: setup-guide.md
[style]: docs/style.md
[roadmap]: https://example.com/tidepool/roadmap
FILE:examples/setup-guide.md
# Setup guide
## Install
Download the installer from the [releases page](https://github.com/example/tidepool/releases)
or build from source (see [Building](#building-from-source)).

## Configure the team folder
Open **Settings > Team folder** and paste the folder link. The folder must
already be shared with you (see [Sharing](./README.md#Usage)).
## Building from source
Clone the repository and run `make build`, then continue with the
[Quick start](README.md#quick-start).
## Troubleshooting
If sync stalls, check the log at [/Users/ana/tidepool/logs/sync.log](/Users/ana/tidepool/logs/sync.log)
or ask in the [support channel][support].
Back to the [README](readme.md#contents).
FILE:scripts/check_md_links.py
#!/usr/bin/env python3
"""Markdown link checker (Python 3 standard library only, fully offline).
Scans a docs folder (or single files) and reports link problems that break readers
before anyone clicks: relative links to files that do not exist, #anchors that match
no heading in the target file, duplicate heading anchors, file-name case mismatches
that only work on macOS/Windows, links to absolute local paths, empty links,
reference-style links whose definition is missing, unused or duplicate reference
definitions, images without alt text and bare URLs. It never touches the network;
use --external to list every external URL (with where it is used) so you can check
them separately.
Anchors follow GitHub's slug rules: heading text with inline markup removed,
lower-cased, punctuation dropped (letters, digits, spaces, "-" and "_" kept), each
space turned into "-", duplicates suffixed -1, -2, ... Explicit anchors from
<a name="x">, <a id="x">, id="x" on HTML tags and {#x} heading attributes count too.
Usage:
python3 check_md_links.py PATH [PATH ...] [--root DIR] [--min-severity LOW|MEDIUM|HIGH]
[--ignore CODE,CODE] [--external] [--json]
python3 check_md_links.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings (broken links, missing anchors or
reference definitions), 2 = bad input (path not found, no Markdown files).
"""
import argparse, json, os, re, sys, tempfile, unicodedata
from urllib.parse import unquote
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
MD_EXT = (".md", ".markdown", ".mdx")
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "_site", "site", "build", "dist"}
FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
ATX_RE = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$")
SETEXT_RE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
REFDEF_RE = re.compile(r"^ {0,3}\[([^\]]+)\]:[ \t]*(<[^>]*>|\S+)(?:[ \t]+(\"[^\"]*\"|'[^']*'|\([^)]*\)))?[ \t]*$")
INLINE_RE = re.compile(r"(!?)\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*(<[^>]*>|[^\s()]*(?:\([^\s()]*\)[^\s()]*)*)(?:\s+(\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*\)")
REFLINK_RE = re.compile(r"(!?)\[((?:[^\[\]\\]|\\.)*)\]\[([^\]]*)\]")
SHORTCUT_RE = re.compile(r"(?<![\]\\!])\[([^\[\]]+)\](?![\[(:])")
AUTOLINK_RE = re.compile(r"<((?:https?|ftp)://[^>\s]+|mailto:[^>\s]+)>")
BARE_URL_RE = re.compile(r"(?<![<(\"'=\w/])(https?://[^\s<>)\]\"']+)")
HTML_ANCHOR_RE = re.compile(r"<[a-zA-Z][^>]*?\b(?:id|name)\s*=\s*[\"']([^\"']+)[\"']")
HTML_HREF_RE = re.compile(r"<a\b[^>]*?\bhref\s*=\s*[\"']([^\"']*)[\"']", re.I)
HTML_IMG_RE = re.compile(r"<img\b([^>]*)>", re.I)
ATTR_ID_RE = re.compile(r"\s*\{#([\w\-:.]+)[^}]*\}\s*$")
SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
WIN_ABS_RE = re.compile(r"^[a-zA-Z]:[\\/]")
def finding(sev, code, path, line, snippet, problem, fix):
return {"severity": sev, "code": code, "file": path, "line": line,
"snippet": snippet.strip()[:120], "problem": problem, "fix": fix}
def strip_inline_markup(text):
t = re.sub(r"`([^`]*)`", r"\1", text)
t = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", t)
t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t)
t = re.sub(r"\[([^\]]*)\]\[[^\]]*\]", r"\1", t)
t = re.sub(r"<[^>]+>", "", t)
t = re.sub(r"(\*\*|\*|~~)(?=\S)(.+?)(?<=\S)\1", r"\2", t)
t = re.sub(r"(?<!\w)(__|_)(?=\S)(.+?)(?<=\S)\1(?!\w)", r"\2", t) # _x_ only at word edges
return t
def github_slug(text):
t = strip_inline_markup(text).strip().lower()
out = []
for ch in t:
cat = unicodedata.category(ch)
if ch in "-_ " or cat[0] in "LN" or cat == "Mn":
out.append("-" if ch == " " else ch)
return "".join(out)
def mask_code(lines):
"""Return (block-masked lines, fully masked lines): fenced/indented code blocks blanked;
the second list also masks inline code spans (headings keep their code text)."""
out, fence = [], None
prev_blank = True
for line in lines:
m = FENCE_RE.match(line)
if fence:
if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line.strip()[len(m.group(1)):].strip():
fence = None
out.append("")
continue
if m:
fence = m.group(1)
out.append("")
continue
if prev_blank and (line.startswith(" ") or line.startswith("\t")) and line.strip():
out.append("")
continue
prev_blank = not line.strip()
out.append(line)
inline = [re.sub(r"(`+)(.+?)\1", lambda mm: mm.group(1) + "x" * len(mm.group(2)) + mm.group(1), l) for l in out]
return out, inline
def parse_file(text):
"""Return dict with anchors (slug->line), duplicates, headings, links, refdefs, images, bare urls."""
raw = text.splitlines()
blocks, lines = mask_code(raw)
anchors, dups, counts = {}, [], {}
refdefs, links, bare, html_imgs = {}, [], [], []
dup_defs = []
for i, line in enumerate(lines, 1):
heading = None
m = ATX_RE.match(blocks[i - 1])
if m:
heading = m.group(2)
elif i >= 2 and SETEXT_RE.match(line) and blocks[i - 2].strip() and not ATX_RE.match(blocks[i - 2]) \
and not blocks[i - 2].lstrip().startswith(("-", "*", ">", "|")) and not REFDEF_RE.match(blocks[i - 2]):
if line.strip().startswith("=") or (i >= 3 and not blocks[i - 3].strip()) or i == 2:
heading = blocks[i - 2].strip()
if heading is not None:
am = ATTR_ID_RE.search(heading)
if am:
anchors.setdefault(am.group(1), i)
heading = heading[:am.start()]
base = github_slug(heading)
n = counts.get(base, 0)
counts[base] = n + 1
slug = base if n == 0 else f"{base}-{n}"
if n > 0:
dups.append((i, heading.strip(), base, slug))
anchors.setdefault(slug, i)
for am in HTML_ANCHOR_RE.finditer(blocks[i - 1]):
anchors.setdefault(am.group(1), i)
d = REFDEF_RE.match(line)
if d:
label = norm_label(d.group(1))
target = d.group(2).strip("<>")
if label in refdefs:
dup_defs.append((i, d.group(1), refdefs[label][1]))
else:
refdefs[label] = (target, i, d.group(1))
continue
spans = []
for lm in INLINE_RE.finditer(line):
target = lm.group(3).strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
links.append({"kind": "image" if lm.group(1) else "link", "text": lm.group(2), "target": target,
"line": i, "snippet": lm.group(0), "ref": None})
spans.append(lm.span())
for rm in REFLINK_RE.finditer(line):
if any(a <= rm.start() < b for a, b in spans):
continue
label = rm.group(3) or rm.group(2)
links.append({"kind": "image" if rm.group(1) else "link", "text": rm.group(2), "target": None,
"line": i, "snippet": rm.group(0), "ref": norm_label(label), "explicit": True})
spans.append(rm.span())
for sm in SHORTCUT_RE.finditer(line):
if any(a <= sm.start() < b for a, b in spans):
continue
if re.match(r"^[ xX]$", sm.group(1)) or line[sm.end():sm.end() + 1] == "]":
continue # task list box or nested
links.append({"kind": "image" if sm.start() > 0 and line[sm.start() - 1] == "!" else "link",
"text": sm.group(1), "target": None, "line": i, "snippet": sm.group(0),
"ref": norm_label(sm.group(1)), "explicit": False})
spans.append(sm.span())
for hm in HTML_HREF_RE.finditer(line):
links.append({"kind": "link", "text": "", "target": hm.group(1), "line": i, "snippet": hm.group(0), "ref": None, "html": True})
for im in HTML_IMG_RE.finditer(line):
attrs = im.group(1)
src = re.search(r"\bsrc\s*=\s*[\"']([^\"']*)[\"']", attrs, re.I)
alt = re.search(r"\balt\s*=\s*[\"']([^\"']*)[\"']", attrs, re.I)
links.append({"kind": "image", "text": alt.group(1) if alt else None, "target": src.group(1) if src else "",
"line": i, "snippet": im.group(0), "ref": None, "html": True})
cleaned = AUTOLINK_RE.sub(" ", line)
cleaned = INLINE_RE.sub(" ", cleaned)
cleaned = re.sub(r"<[^>]+>", " ", cleaned)
for bm in BARE_URL_RE.finditer(cleaned):
bare.append((i, bm.group(1).rstrip(".,;:!?")))
for am in AUTOLINK_RE.finditer(line):
links.append({"kind": "autolink", "text": am.group(1), "target": am.group(1), "line": i, "snippet": am.group(0), "ref": None})
return {"anchors": anchors, "dups": dups, "refdefs": refdefs, "dup_defs": dup_defs, "links": links, "bare": bare}
def norm_label(s):
return re.sub(r"\s+", " ", s.strip()).casefold()
def is_external(target):
return bool(SCHEME_RE.match(target)) and not WIN_ABS_RE.match(target)
def exists_exact_case(path):
"""True/False for exact-case existence, plus the real name if only a case-insensitive match exists."""
if os.path.exists(path):
head, tail = os.path.split(os.path.abspath(path))
try:
names = os.listdir(head or ".")
except OSError:
return True, None
if tail in names or not tail:
return True, None
for n in names:
if n.lower() == tail.lower():
return False, n
return True, None
head, tail = os.path.split(os.path.abspath(path))
try:
for n in os.listdir(head):
if n.lower() == tail.lower():
return False, n
except OSError:
pass
return False, None
class Checker:
def __init__(self, root, ignore=()):
self.root = os.path.abspath(root)
self.ignore = set(ignore)
self.cache = {}
self.findings = []
self.external = {}
def parsed(self, path):
path = os.path.abspath(path)
if path not in self.cache:
with open(path, encoding="utf-8", errors="replace") as fh:
self.cache[path] = parse_file(fh.read())
return self.cache[path]
def add(self, *a):
f = finding(*a)
if f["code"] not in self.ignore:
self.findings.append(f)
def rel(self, p):
return os.path.relpath(p, self.root)
def check_file(self, path):
info = self.parsed(path)
rp = self.rel(path)
for line, heading, base, slug in info["dups"]:
self.add("MEDIUM", "DUPLICATE_ANCHOR", rp, line, heading,
f"heading '{heading}' repeats an earlier anchor #{base}; this one becomes #{slug}, so links to #{base} land on the first one",
"make the heading unique (add context) or add an explicit anchor")
for line, label, first in info["dup_defs"]:
self.add("MEDIUM", "DUPLICATE_REF_DEF", rp, line, f"[{label}]:",
f"reference [{label}] is defined again (first definition on line {first}); only the first one is used",
"delete or rename the duplicate definition")
used = set()
for ln in info["links"]:
if ln["ref"] is not None:
d = info["refdefs"].get(ln["ref"])
if d is None:
if ln.get("explicit"):
self.add("HIGH", "MISSING_REF_DEF", rp, ln["line"], ln["snippet"],
f"reference-style link uses [{ln['ref']}] but no '[{ln['ref']}]: URL' definition exists, so it renders as plain text",
"add the definition at the bottom of the file or fix the label")
continue
used.add(ln["ref"])
target = d[0]
else:
target = ln["target"]
self.check_link(path, rp, ln, target)
for label, (target, line, orig) in info["refdefs"].items():
if label not in used:
self.add("LOW", "UNUSED_REF_DEF", rp, line, f"[{orig}]: {target}",
f"reference definition [{orig}] is never used", "delete it or link to it")
for line, url in info["bare"]:
self.add("LOW", "BARE_URL", rp, line, url,
"bare URL: not clickable in some renderers and read out character by character by screen readers",
f"use [descriptive text]({url}) or <{url}>")
self.external.setdefault(url, []).append(f"{rp}:{line}")
def check_link(self, path, rp, ln, target):
kind = ln["kind"]
if kind == "image":
alt = ln["text"]
if alt is None or not alt.strip():
self.add("MEDIUM", "MISSING_ALT", rp, ln["line"], ln["snippet"],
"image has no alt text", "describe what the image shows or why it is there (alt=\"\" only for purely decorative images in HTML)")
if target is None:
return
if not target.strip():
self.add("MEDIUM", "EMPTY_LINK", rp, ln["line"], ln["snippet"], f"{kind} has an empty target", "add the URL or path, or remove the brackets")
return
if kind == "link" and not ln.get("html") and not strip_inline_markup(ln["text"]).strip():
self.add("MEDIUM", "EMPTY_LINK_TEXT", rp, ln["line"], ln["snippet"], "link has no visible text", "put words inside the [ ]")
if target.startswith("mailto:") or target.startswith("tel:"):
return
if target.startswith("file:") or WIN_ABS_RE.match(target) or re.match(r"^/(home|Users|tmp|var|mnt|opt)/", target):
self.add("MEDIUM", "LOCAL_ABSOLUTE_PATH", rp, ln["line"], ln["snippet"],
f"'{target}' points at a path on the author's machine; it is broken for everyone else",
"use a path relative to this file")
return
if is_external(target):
if target.startswith(("http://", "https://", "ftp://")):
self.external.setdefault(target, []).append(f"{rp}:{ln['line']}")
return
target_path, _, frag = target.partition("#")
target_path = unquote(target_path.split("?")[0])
frag = unquote(frag)
if target_path:
if target_path.startswith("/"):
full = os.path.normpath(os.path.join(self.root, target_path.lstrip("/")))
else:
full = os.path.normpath(os.path.join(os.path.dirname(path), target_path))
ok, real = exists_exact_case(full)
if not ok and real:
self.add("MEDIUM", "CASE_MISMATCH", rp, ln["line"], ln["snippet"],
f"'{target_path}' only matches '{real}' with different letter case; works on macOS/Windows, breaks on Linux and GitHub Pages",
f"use the exact name '{real}'")
full = os.path.join(os.path.dirname(full), real)
elif not ok:
code = "BROKEN_IMAGE" if kind == "image" else "BROKEN_LINK"
self.add("HIGH", code, rp, ln["line"], ln["snippet"],
f"'{target_path}' does not exist" + (f" (resolved to {self.rel(full)})" if self.rel(full) != os.path.normpath(target_path) else ""),
"fix the path (moved or renamed file?) or remove the link")
return
if not os.path.abspath(full).startswith(self.root + os.sep) and os.path.abspath(full) != self.root:
self.add("LOW", "OUTSIDE_ROOT", rp, ln["line"], ln["snippet"],
f"'{target_path}' leaves the docs root; it breaks if the docs are published on their own",
"link to the published URL or copy the file into the docs")
else:
full = path
if frag:
if os.path.isdir(full) or not full.lower().endswith(MD_EXT):
return
info = self.parsed(full)
if frag in info["anchors"]:
return
if frag.lower() in info["anchors"]:
self.add("MEDIUM", "ANCHOR_CASE", rp, ln["line"], ln["snippet"],
f"#{frag} only matches #{frag.lower()} case-insensitively; GitHub anchors are lower-case",
f"use #{frag.lower()}")
return
if re.match(r"^L\d+(-L\d+)?$", frag):
return # GitHub line anchors
key = frag.lower().replace("-", "")
close = [a for a in info["anchors"] if a.replace("-", "") == key] or \
[a for a in info["anchors"] if len(key) >= 5 and key[:6] in a.replace("-", "")]
hint = f" Did you mean #{close[0]}?" if close else ""
self.add("HIGH", "MISSING_ANCHOR", rp, ln["line"], ln["snippet"],
f"#{frag} matches no heading or anchor in {self.rel(full)}.{hint}",
"link to an existing heading slug, or add the heading / an explicit anchor")
def collect(paths):
files = []
for p in paths:
if os.path.isdir(p):
for dirpath, dirnames, filenames in os.walk(p):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
files += [os.path.join(dirpath, f) for f in sorted(filenames) if f.lower().endswith(MD_EXT)]
elif os.path.isfile(p):
files.append(p)
else:
raise FileNotFoundError(p)
return files
def run(paths, root=None, ignore=(), min_sev="LOW"):
files = collect(paths)
if root is None:
root = paths[0] if os.path.isdir(paths[0]) else os.path.dirname(os.path.abspath(paths[0]))
ck = Checker(root, ignore)
for f in files:
ck.check_file(os.path.abspath(f))
lim = SEV_ORDER[min_sev]
found = [f for f in ck.findings if SEV_ORDER[f["severity"]] <= lim]
found.sort(key=lambda f: (f["file"], f["line"], SEV_ORDER[f["severity"]]))
return files, found, ck.external
def report(files, found, external, show_external, roots):
counts = {s: sum(1 for f in found if f["severity"] == s) for s in SEV_ORDER}
out = [f"# Markdown link check: {', '.join(roots)}", "",
f"Files: {len(files)} Findings: {len(found)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']}) External URLs: {len(external)} (not checked, offline)", ""]
if not found:
out.append("No findings.")
else:
out += ["| Sev | Code | File:Line | Problem | Fix |", "|---|---|---|---|---|"]
for f in found:
out.append(f"| {f['severity']} | {f['code']} | {f['file']}:{f['line']} | {f['problem'].replace('|', '/')} | {f['fix'].replace('|', '/')} |")
if show_external and external:
out += ["", "## External URLs to check (no network calls were made)", "", "| URL | Used at |", "|---|---|"]
for url in sorted(external):
out.append(f"| {url} | {', '.join(external[url])} |")
return "\n".join(out)
def self_test():
cases = []
def case(name, files, expect, absent=(), target="README.md"):
cases.append((name, files, expect, absent, target))
case("clean file", {"README.md": "# Title\n\nSee [setup](setup.md#install).\n", "setup.md": "# Setup\n\n## Install\n"}, [], ["BROKEN_LINK", "MISSING_ANCHOR"])
case("broken relative link", {"README.md": "[x](docs/missing.md)\n"}, ["BROKEN_LINK"])
case("broken image", {"README.md": "\n"}, ["BROKEN_IMAGE"])
case("missing anchor other file", {"README.md": "[x](a.md#nope)\n", "a.md": "# Yes\n"}, ["MISSING_ANCHOR"])
case("missing anchor same file", {"README.md": "# Intro\n[x](#usage)\n"}, ["MISSING_ANCHOR"])
case("anchor with punctuation", {"README.md": "## What's new? (v2.0)\n[x](#whats-new-v20)\n"}, [], ["MISSING_ANCHOR"])
case("anchor with inline code", {"README.md": "## The `--dry-run` flag\n[x](#the---dry-run-flag)\n"}, [], ["MISSING_ANCHOR"])
case("underscores inside words kept", {"README.md": "## snake_case_name and _emphasis_\n[x](#snake_case_name-and-emphasis)\n"}, [], ["MISSING_ANCHOR"])
case("unicode heading anchor", {"README.md": "## Über uns\n[x](#über-uns)\n"}, [], ["MISSING_ANCHOR"])
case("duplicate heading", {"README.md": "## Usage\n## Usage\n[x](#usage-1)\n"}, ["DUPLICATE_ANCHOR"], ["MISSING_ANCHOR"])
case("setext heading", {"README.md": "Install\n=======\n[x](#install)\n"}, [], ["MISSING_ANCHOR"])
case("explicit html anchor", {"README.md": '<a id="faq"></a>\n[x](#faq)\n'}, [], ["MISSING_ANCHOR"])
case("attribute id anchor", {"README.md": "## Setup {#custom-setup}\n[x](#custom-setup)\n"}, [], ["MISSING_ANCHOR"])
case("anchor case", {"README.md": "## Usage\n[x](#Usage)\n"}, ["ANCHOR_CASE"], ["MISSING_ANCHOR"])
case("near-miss hint", {"README.md": "## Getting Started\n[x](#gettingstarted)\n"}, ["MISSING_ANCHOR"])
case("missing ref def", {"README.md": "See [the guide][guide].\n"}, ["MISSING_REF_DEF"])
case("unused ref def", {"README.md": "Text.\n\n[old]: https://example.com\n"}, ["UNUSED_REF_DEF"])
case("used ref def shortcut", {"README.md": "See [guide].\n\n[guide]: setup.md\n", "setup.md": "# S\n"}, [], ["UNUSED_REF_DEF", "BROKEN_LINK"])
case("ref def case-insensitive", {"README.md": "See [Guide][G].\n\n[g]: setup.md\n", "setup.md": "# S\n"}, [], ["MISSING_REF_DEF", "UNUSED_REF_DEF"])
case("ref def broken target", {"README.md": "See [g][].\n\n[g]: nope.md\n"}, ["BROKEN_LINK"])
case("duplicate ref def", {"README.md": "[a][x]\n\n[x]: https://a.example\n[x]: https://b.example\n"}, ["DUPLICATE_REF_DEF"])
case("image without alt", {"README.md": "\n", "shot.png": "png"}, ["MISSING_ALT"], ["BROKEN_IMAGE"])
case("html img without alt", {"README.md": '<img src="shot.png" width="300">\n', "shot.png": "png"}, ["MISSING_ALT"])
case("bare url", {"README.md": "Docs at https://example.com/docs today.\n"}, ["BARE_URL"])
case("autolink is not bare", {"README.md": "Docs at <https://example.com/docs>.\n"}, [], ["BARE_URL"])
case("code block ignored", {"README.md": "```\n[x](missing.md) https://x.example\n```\n"}, [], ["BROKEN_LINK", "BARE_URL"])
case("inline code ignored", {"README.md": "Use `[x](missing.md)` syntax.\n"}, [], ["BROKEN_LINK"])
case("case mismatch", {"README.md": "[c](CONTRIBUTING.MD)\n", "CONTRIBUTING.md": "# C\n"}, ["CASE_MISMATCH"], ["BROKEN_LINK"])
case("local absolute path", {"README.md": "[x](/Users/ana/repo/docs/a.md)\n"}, ["LOCAL_ABSOLUTE_PATH"])
case("empty target", {"README.md": "[todo]()\n"}, ["EMPTY_LINK"])
case("url-encoded path", {"README.md": "[x](my%20notes.md)\n", "my notes.md": "# N\n"}, [], ["BROKEN_LINK"])
case("angle-bracket path with spaces", {"README.md": "[x](<my notes.md>)\n", "my notes.md": "# N\n"}, [], ["BROKEN_LINK"])
case("directory link ok", {"README.md": "[api](api/)\n", "api/index.md": "# API\n"}, [], ["BROKEN_LINK"])
case("root-relative link", {"README.md": "[x](/guide/a.md)\n", "guide/a.md": "# A\n"}, [], ["BROKEN_LINK"])
case("github line anchor", {"README.md": "[x](setup.md#L10)\n", "setup.md": "# S\n"}, [], ["MISSING_ANCHOR"])
case("task list not a link", {"README.md": "- [ ] write docs\n- [x] done\n"}, [], ["MISSING_REF_DEF"])
case("mailto ignored", {"README.md": "[mail](mailto:team@example.com)\n"}, [], ["BROKEN_LINK"])
case("nested subdir relative", {"README.md": "x", "guide/a.md": "[b](../b.md#top)\n", "b.md": "# Top\n"}, [], ["BROKEN_LINK", "MISSING_ANCHOR"], "guide/a.md")
passed = 0
for name, files, expect, absent, target in cases:
with tempfile.TemporaryDirectory() as d:
for rel, body in files.items():
p = os.path.join(d, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as fh:
fh.write(body)
_, found, _ = run([d], root=d)
codes = {f["code"] for f in found if f["file"] == target}
ok = all(c in codes for c in expect) and not any(c in codes for c in absent)
if name == "clean file":
ok = ok and not found
if name == "near-miss hint":
ok = ok and any("Did you mean #getting-started" in f["problem"] for f in found)
passed += ok
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> got {sorted(codes)}"))
# external URL listing and report rendering
with tempfile.TemporaryDirectory() as d:
with open(os.path.join(d, "a.md"), "w") as fh:
fh.write("[x](https://example.com/a) and [y](https://example.com/a)\n")
files, found, ext = run([d])
ok = list(ext) == ["https://example.com/a"] and len(ext["https://example.com/a"]) == 2
text = report(files, found, ext, True, [d])
ok = ok and "External URLs to check" in text and "No findings." in text
passed += ok
print(("PASS " if ok else "FAIL ") + "external URLs listed once with locations, no network")
total = len(cases) + 1
print(f"{passed}/{total} passed")
return 0 if passed == total else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Offline Markdown link and anchor checker")
ap.add_argument("paths", nargs="*")
ap.add_argument("--root", help="docs root for /absolute links and the OUTSIDE_ROOT check (default: first folder)")
ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
ap.add_argument("--external", action="store_true", help="list external URLs to check separately")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.paths:
ap.print_usage(sys.stderr)
return 2
try:
files, found, ext = run(a.paths, a.root, [c.strip() for c in a.ignore.split(",") if c.strip()], a.min_severity)
except FileNotFoundError as e:
print(f"error: path not found: {e}", file=sys.stderr)
return 2
if not files:
print("error: no Markdown files found", file=sys.stderr)
return 2
if a.json:
print(json.dumps({"files": len(files), "findings": found, "external": ext if a.external else {}}, indent=2, ensure_ascii=False))
else:
print(report(files, found, ext, a.external, a.paths))
return 1 if any(f["severity"] == "HIGH" for f in found) else 0
if __name__ == "__main__":
sys.exit(main())Source: github.com/f/prompts.chat · Contributor: f
Start from the step 2 image "Bamboo Forest Footpath with Fireflies and a Wooden Footbridge at Dusk" as the exact first frame, and animate it into a calm 6-second cinematic shot. Keep the stepping-stone path, the shallow stream running down its center, the small arched wooden footbridge in the middle distance, the tall bamboo walls on both sides, the gap of deep blue twilight sky, the mist around the bridge, and the composition exactly as in the image. The camera makes a very slow, smooth dolly forward along the path toward the bridge. The yellow-green fireflies drift gently through the air and blink softly on and off at different times. The bamboo stems and leaves sway slightly in a light breeze, all in the same direction. Low mist rolls slowly over the path and around the bridge, and the stream glints and ripples as it flows toward the camera. The cool blue twilight light stays steady. Natural, realistic motion, no cuts, no people, no animals, no new objects, no text. Soft sound of trickling water, rustling bamboo leaves, and evening insects.Source: github.com/f/prompts.chat · Contributor: f
A quiet stepping-stone footpath runs straight ahead through a tall bamboo forest at dusk, lit by hundreds of glowing fireflies, toward a small arched wooden footbridge in the middle distance, seen from a low, centered viewpoint at the start of the path, in a wide landscape 16:9 frame. Large flat grey stepping stones lead from the bottom center of the frame toward the bridge, with a shallow stream trickling down the middle of the path over small stones, flowing out from under the bridge and catching faint blue light. Dense walls of tall, slender green bamboo with visible nodes rise on both sides and lean slightly inward, their leafy tops framing a narrow gap of deep blue twilight sky with soft clouds at the top center. The small wooden bridge has a gentle arch and simple railings, with a pale opening beneath it. Low white mist hangs around the bridge and drifts over the path and the undergrowth. Many tiny yellow-green fireflies glow scattered through the air, among the bamboo, and over the ferns, grasses, and mossy rocks along both edges of the path. Cool blue twilight ambience with warm green-yellow firefly accents, photoreal night landscape photography, natural colors, calm and magical mood. No people, no text, no logos.
Source: github.com/f/prompts.chat · Contributor: f
Act as a cinematic landscape designer who writes image-ready and motion-ready scene briefs. Help me design one hero shot of a quiet footpath through a tall bamboo forest at dusk, lit by fireflies. The shot will first be generated as a still image and then animated into a short video that uses that image as its first frame. Your brief must make the still image rich in elements that can move naturally (fireflies drifting and blinking, bamboo stems and leaves swaying, low mist rolling over the ground, a shallow stream glinting, the last blue light of twilight) while keeping the composition calm, symmetrical, readable, and easy to animate. My starting idea: a stone path between tall bamboo at dusk, with fireflies and a little wooden bridge My starting choices: - Path: an old stepping-stone path running straight away from the camera toward the center of the frame - Bamboo: tall, dense green bamboo stems rising on both sides, their tops closing into a canopy with a gap of sky in the middle - Water: a shallow stream trickling down the middle of the path over stones - Focal point: a small arched wooden footbridge with railings crossing in the middle distance - Fireflies: many small glowing yellow-green fireflies scattered through the air and the undergrowth - Atmosphere: low mist hanging around the bridge and over the path - Time and light: deep blue twilight just after sunset, a few clouds in the sky gap - Visual style: photoreal night landscape photography, natural colors - Mood words: calm, magical, hushed, inviting First ask me, in one short message, for anything missing: the camera height (low near the stones, standing eye level, or slightly raised), how centered and symmetrical I want the path, how bright and how many fireflies, how much mist, whether the bridge should be close or far, and anything to avoid (people, animals, lanterns, buildings, text, logos, or copying a specific real photograph or a named landmark). Then produce the brief with these sections: 1. **Scene Overview**: two or three sentences on the place, the moment, the light, and the feeling. Describe an imagined bamboo forest in an East Asian spirit, not a copy of a specific photograph or a famous real grove. 2. **Path and Stream**: the shape and line of the stepping stones toward the vanishing point, the stone texture and moss, how the shallow stream runs down the path from under the bridge, and where the water catches light. 3. **Bamboo Walls and Canopy**: stem thickness, spacing, and nodes on each side, how they rise and lean inward, the leaf canopy, the gap of sky, and the ferns and grasses at their feet. 4. **The Footbridge**: size in the frame, shape of the arch, the wooden railings, the darker opening under it, and how mist gathers around it. 5. **Fireflies**: density, size, color, how they are spread through the air, the undergrowth, and the depth layers, and how their glow should read against the dark bamboo without looking like stars or bokeh dust. 6. **Light, Sky, and Mist**: the twilight sky gradient in the canopy gap, the cool blue ambient light, the warm-green firefly accents, the layers of mist, and how soft the shadows are. 7. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (path line, stream position, bridge position and shape, bamboo walls on both sides, sky gap shape, mist zones, camera position, framing, overall firefly distribution). 8. **Motion Map**: a table of every element that can move in the video, with columns: element, type of motion, direction, speed (very slow, slow, medium), and how strongly it should move. Include the fireflies (drift and blink), the bamboo stems and leaves, the mist, the stream, the light, and the camera move. Keep total motion subtle and physically plausible, with one breeze direction. 9. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the bamboo path, the fireflies, and the footbridge at dusk in the first sentence, ending with: no people, no text, no logos. 10. **Prompt Seed B - Image-to-Video Animation**: a ready-to-paste video prompt under 120 words that starts from the hero still as the first frame, describes the fireflies drifting and blinking, the bamboo swaying gently, the mist rolling low, the stream glinting, one very slow dolly forward along the path toward the bridge for about 6 seconds, and says to keep the path, bamboo, bridge, sky, and framing exactly as in the image with no new objects. Rules: no people, animals, lanterns, or buildings; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (bamboo bends from the base and does not stretch, the bridge and stones never move, fireflies drift slowly and do not streak, the light does not jump). Use short paragraphs and lists.
Source: github.com/f/prompts.chat · Contributor: f
A loose, atmospheric watercolor painting of covered gondolas moored on the Grand Canal in Venice at dawn, in a landscape 4:3 frame on textured cold-press watercolor paper. In the lower left, three long black gondolas rest side by side, wrapped in dark blue-grey covers, one with a pale tan tarp, the front gondola's curved prow and metal prow ornament rising at the far left edge. Tall wooden mooring poles with red-and-white spiral stripes and small pointed caps stand in the water across the middle and right of the frame, some linked to the gondolas by sagging ropes and chains. Across the canal, a hazy, softly painted skyline of old palazzos stretches to the right, with a slender bell tower, and on the left a large domed baroque basilica with smaller domes and lanterns, in the spirit of Santa Maria della Salute, silhouetted against the light. The sky glows in soft peach, apricot, and lavender washes with drifting clouds. The calm water reflects the poles, gondolas, and sky in broken vertical strokes of violet, pink, and white. Visible brushstrokes, wet-in-wet blooms, granulating pigments, soft edges, and white paper showing through. Peaceful, romantic early-morning mood. No people, no text, no signature.
Source: github.com/f/prompts.chat · Contributor: f
A photorealistic macro photograph of a round glass bowl terrarium on a wooden desk, holding a tiny moss landscape with a miniature cabin and a waterfall, in a square 1:1 frame. The clear, open-topped glass bowl fills most of the frame and is covered with small condensation droplets on the inside. On the left, a tiny weathered wooden cabin with a steep shingled roof and a small brick chimney sits on a mossy rise; a single window glows warm amber as if a lamp is lit inside. From its door, a winding path of flat grey stepping stones curves down through the moss toward the front of the bowl. On the right, a thin silvery waterfall pours between jagged grey rocks into a small, still pond near the bottom center. Around everything grow plump cushions of bright green moss, delicate ferns, and small leafy plants, with dark soil and pebbles visible through the curved glass at the base. The bowl stands on a warm brown wooden desk with visible grain; the background is a soft blurred window with green foliage and daylight. Soft natural side light, shallow depth of field, crisp detail on the droplets and moss, cozy and magical miniature-world mood. No people, no text, no logos.
Source: github.com/f/prompts.chat · Contributor: f
A photorealistic photograph of a village blacksmith forging a glowing blade inside a rustic stone smithy, in a wide landscape 16:9 frame. A bearded man in his forties with tousled dark hair, a sooty dark long-sleeved work shirt with the sleeves pushed up, and a worn, scorched brown leather apron stands behind a heavy steel anvil in the center, his head bowed in concentration. With one hand he grips long iron tongs that hold a bright orange-hot blade flat on the anvil face; with the other he raises a short-handled forging hammer just above it, mid-strike. A burst of tiny orange sparks sprays from the blade and arcs out to the right and down over the anvil. On the left, a coal forge glows with yellow-orange flames and red embers under a black metal hood and chimney. Behind him, a rough whitewashed stone wall holds a wooden rack with a row of hanging hammers and long-handled tongs. On the right, a small multi-pane window lets in cool, soft grey-blue daylight that falls on the wall and the windowsill. Warm firelight from the forge and the hot metal lights his face, forearms, and apron, contrasting with the cool window light. Dust and soot in the air, gritty textures of leather, iron, and stone, shallow depth of field, cinematic documentary photography, natural colors. No text, no logos.
Source: github.com/f/prompts.chat · Contributor: f
role: Friendly, safety-minded family camping planner who helps parents organize a relaxed campground weekend with children, from booking and packing to meals, sleep, activities, and leaving no trace, so the trip is fun for the kids and manageable for the adults task: > Turn my family, destination, dates, and gear into a complete family camping weekend plan as YAML: a trip summary with readiness and risks, a booking and permits checklist, a weather and safety plan, a shared and per-person packing list by category, a campsite setup plan, a simple meal plan with a cooler and food storage strategy, a kid-friendly schedule for each day with quiet times and backup rainy-day ideas, sleep and comfort tips for children, chores for every family member, a pack-up and leave-no-trace checklist, and a short post-trip review. inputs: family: two adults, kids aged 4 and 9, first time camping as a family destination_type: a staffed campground by a lake, about 2 hours by car, drive-in tent sites dates_and_length: Friday afternoon to Sunday noon in late May expected_weather: days around 20 C, nights around 8 C, chance of showers site_facilities: toilets and showers nearby, drinking water tap, fire rings allowed if no ban gear_we_own: one 4-person tent, two adult sleeping bags, a small camping stove, a cooler, headlamps gear_missing: kids' sleeping bags and sleeping pads, a tarp, camp chairs cooking_style: simple meals, some cooking on the stove, one campfire dinner dietary_needs: the 9-year-old is vegetarian, no allergies activities_wanted: short lakeside hike, swimming if warm, nature scavenger hunt, s'mores special_needs: the 4-year-old still naps after lunch and wakes up at night sometimes budget: moderate, prefer borrowing or renting gear over buying car_space: a mid-size hatchback with a roof box ask_first: - The campground name or type, whether you have a reservation, and the check-in and check-out times - Each child's age, sleep habits, fears or comfort items, swimming ability, and anything that makes them anxious outdoors - Which gear you own, can borrow, or plan to rent, and how much space you have in the car - Any allergies, medical needs, or medicines anyone in the family needs to bring - Whether fires, alcohol, pets, or generators are allowed at your site, and if there are wildlife rules such as food lockers or bear-safe storage rules: - Never invent campground rules, fire bans, permit requirements, opening dates, or weather. Write them as items to confirm with the campground or the local park authority and flag them with confirm true. - Plan for safety first, then comfort, then fun. Include a check of the official weather forecast and fire danger before leaving, a basic first aid kit, sun protection, water safety rules for the kids, and a simple lost-child plan with a meeting point. - Keep children warm and dry at night. Insulation under the sleeping bag matters as much as the bag; give layers for nights at the expected low temperature and a dry change of clothes for each child. - Meals must be simple, safe, and kid-friendly. Plan the cooler so raw meat stays below ready-to-eat food, list what to cook first, keep perishables cold, and store all food and trash according to the site's wildlife rules. - Build each day around the youngest child's energy and nap times, with short activities, free play, and quiet time, and a rainy-day backup for every outdoor activity. - Give every family member, including the kids, age-appropriate jobs for setup, meals, and pack-up. - Follow leave-no-trace basics: stay on trails, use existing fire rings, burn only allowed wood and never trash, put fires out completely with water until cold, pack out all rubbish, and respect quiet hours and other campers. - Packing lists show who it is for, whether we own it, need to borrow or rent it, or must buy it, and mark essentials. - Output only valid YAML matching output_format. No prose outside the YAML. output_format: summary: trip: string nights: number readiness: ready | almost_ready | needs_work top_risks: - string assumptions: - string open_questions: - string booking_and_permits: - item: string status: done | to_do | to_confirm confirm: boolean notes: string weather_and_safety: forecast_checks: - when: string what_to_check: string first_aid_kit: - string water_safety_rules: - string lost_child_plan: meeting_point: string what_kids_should_do: - string fire_safety: - string wildlife_and_food_storage: - string packing_list: - category: shelter | sleep | kitchen | clothing | kids | hygiene | safety | fun | other items: - item: string for: string source: own | borrow | rent | buy essential: boolean campsite_setup: arrival_steps: - step: string who: string layout_tips: - string meal_plan: - day: string meal: breakfast | lunch | dinner | snack menu: string vegetarian_option: string cook_method: stove | campfire | no_cook prep_at_home: string cooler_plan: packing_order: - string keep_cold_tips: - string eat_first: - string daily_schedule: - day: string blocks: - time: string activity: string rainy_day_backup: string notes: string sleep_and_comfort: - child: string tips: - string family_jobs: - person: string jobs: - string pack_up_and_leave_no_trace: - string post_trip_review: questions: - string gear_to_add_next_time: - string
Source: github.com/f/prompts.chat · Contributor: f
{
"role": "Calm, practical compensation negotiation coach who helps people prepare for one specific pay conversation, either a raise with their current employer or the salary step of a single job offer, by building evidence, a clear number, a walk-away point, and polite word-for-word scripts, without inventing market data or encouraging bluffing",
"task": "Turn my role, my situation, my evidence, and the conversation I am facing into a single strict JSON prep sheet: a situation summary, an evidence-based case for my value, a market data plan that tells me where to check ranges instead of guessing them, my target, ask, and walk-away numbers with the reasoning, the non-salary items I can trade, an opening script, answers to the likely pushbacks, a plan for silence or a 'no', a follow-up email draft, and a short rehearsal plan. Never invent salary figures, company policies, budgets, or facts that I did not give.",
"context": {
"conversation_type": "raise review with my current manager",
"current_or_offered_role": "senior data analyst",
"company_type_and_size": "a mid-size software company, about 400 people",
"location_and_work_mode": "Lisbon, hybrid 3 days in the office",
"current_pay": "42000 base, no bonus",
"currency": "EUR",
"time_in_role": "2 years and 4 months",
"achievements": "built the churn dashboard used by sales leadership, automated the monthly reporting that took 3 days, trained two new analysts",
"scope_changes": "now own the pricing analytics area that a departed senior used to cover",
"market_data_i_have": "a friend in a similar role earns more, nothing else yet",
"number_i_hope_for": "about 50000",
"other_options": "no other offer, but recruiters contact me sometimes",
"known_constraints": "annual review cycle in March, my manager said budgets are tight this year",
"meeting_date_and_format": "in 3 weeks, 30 minutes, video call",
"my_style_and_worries": "I get nervous and tend to accept the first answer"
},
"ask_first": [
"Is this a raise or promotion conversation with your current employer, or the salary step of one specific job offer, and who exactly will be in the conversation",
"Your full current package: base pay, bonus, equity, benefits, and any recent increase, with dates",
"Two to four concrete results from the last 12 months with numbers if possible (time saved, revenue, costs, quality, people helped)",
"Any salary ranges you have seen: job posts with ranges, internal pay bands, salary surveys, or recruiter numbers, with the source and date",
"The lowest outcome you would honestly accept, and what you would do if you get less (stay and wait, look elsewhere, accept a non-salary trade)"
],
"rules": [
"Use only my inputs for facts. If something is missing, write 'unknown' in assumptions, add a question to open_questions, and never guess pay figures, bands, budgets, or what my manager or recruiter said.",
"Do not state market salary ranges as facts. In market_data_plan, list the types of sources to check (job posts with published ranges in my location, reputable salary surveys, professional associations, recruiters, internal bands), what to record from each, and how to compare like for like on level, location, and work mode.",
"Set three numbers from my inputs and the market plan: walk_away (the lowest I accept), target (what I realistically aim for), and ask (the first number I say, a reasonable step above target). If the market data is not yet checked, mark the numbers as provisional and say exactly what would change them.",
"Build the value case from my real achievements and scope changes, written as short, specific statements with the business result first. Never exaggerate, take credit for others' work, or add achievements I did not mention.",
"Scripts must be polite, confident, short, and in plain spoken language. No ultimatums, no fake competing offers, no threats to quit, and no personal hardship as the main argument.",
"For every likely pushback (budget is tight, wait for the review cycle, you are already at the top of the band, we cannot go above the offer, what is your expectation), give a calm reply and a concrete next step, such as a timeline, a written commitment, or a non-salary trade.",
"List non-salary items only if they are realistic for my situation, and rank them by value to me: for example title, scope, review date, bonus, equity, extra leave, remote days, learning budget, or signing bonus for offers.",
"Include a silence and 'no' plan: how to pause, what to say, how to ask for the criteria and a date to revisit, and how to end the meeting on good terms.",
"This is preparation and coaching, not legal or tax advice. Mention that pay transparency rules and contract terms vary by country and employer and should be confirmed officially when relevant.",
"Output only valid JSON matching output_format. No markdown, no comments, no prose outside the JSON."
],
"output_format": {
"situation_summary": {
"conversation_type": "raise | promotion | new_offer",
"role": "string",
"current_package": "string",
"meeting": "string",
"readiness": "ready | needs_data | needs_evidence | not_yet",
"readiness_reasons": ["string"],
"assumptions": ["string"],
"open_questions": ["string"]
},
"value_case": [
{
"achievement": "string",
"business_result": "string",
"proof_to_bring": "string",
"one_line_version": "string"
}
],
"market_data_plan": [
{
"source_type": "string",
"what_to_record": "string",
"how_to_compare": "string",
"status": "have | to_check"
}
],
"numbers": {
"currency": "string",
"walk_away": "number | null",
"target": "number | null",
"ask": "number | null",
"provisional": "boolean",
"reasoning": ["string"],
"what_would_change_them": ["string"]
},
"trade_options": [
{
"item": "string",
"value_to_me": "high | medium | low",
"how_to_ask": "string",
"realistic": "boolean"
}
],
"opening_script": {
"first_sentence": "string",
"value_points": ["string"],
"the_ask": "string",
"then": "string"
},
"pushback_responses": [
{
"they_say": "string",
"i_reply": "string",
"next_step_to_request": "string"
}
],
"silence_and_no_plan": {
"if_silence": "string",
"if_no": "string",
"criteria_to_ask_for": ["string"],
"revisit_date_request": "string",
"closing_line": "string"
},
"follow_up_email": {
"send_when": "string",
"subject": "string",
"body": "string"
},
"rehearsal_plan": [
{
"day": "string",
"practice": "string",
"minutes": "number"
}
],
"do_and_dont": {
"do": ["string"],
"dont": ["string"]
},
"disclaimers": ["string"]
}
}Source: github.com/f/prompts.chat · Contributor: f
Act as a Beginner Sourdough Starter Coach. You help complete beginners create a healthy sourdough starter from just flour and water, keep it alive, and bake a first simple loaf, using the kitchen, flour, and schedule they actually have. You think like a patient home baker who teaches small community baking classes: you explain what is happening inside the jar in plain language, you tie every instruction to my room temperature, flour, and daily routine, you give weights in grams and simple ratios, and you are honest that the first one to two weeks are often messy and slow. You never promise a fixed day when the starter will be "ready"; you teach me to read the signs instead. My situation: - Kitchen temperature: around 20 C during the day, cooler at night - Flour I can buy: supermarket all-purpose flour and a small bag of whole wheat flour - Water: tap water, slightly chlorinated - Equipment: a kitchen scale, a large glass jar with a loose lid, a spatula, and a regular home oven - Daily schedule: I work from 9 to 6, I can feed it in the morning and the evening - What I already tried: nothing yet, this is my first starter - What I want to bake first: a simple crusty loaf, later pancakes and pizza with the discard - How often I want to bake: once a week on weekends - Anything I want to avoid: wasting a lot of flour, complicated schedules First, in one short message, ask me for anything important that is missing: whether my kitchen gets much warmer or colder at night, whether I have a warm spot (oven with only the light on, top of the fridge), whether I have a covered pot or baking stone for the oven, whether anyone in the household has a gluten or wheat allergy, and whether I will be away for any days in the next two weeks. Then guide me through these sections: 1. **What a Starter Really Is**: a short, friendly explanation of wild yeast and lactic acid bacteria, why flour and water are enough, why the first days can bubble and then go quiet, and what "active" and "mature" mean. 2. **Starter Words You Will Meet**: a short glossary with term and plain meaning: starter, levain, discard, feeding, ratio (for example 1:1:1), hydration, peak, collapse, hooch, float test, bulk fermentation, proofing, and baker's percentage. 3. **Day-by-Day Setup Plan**: a table for the first 14 days with columns: day, what to do, amounts in grams, what I will probably see, and what is normal versus worth adjusting. Start with whole wheat or a whole wheat and all-purpose mix, then move to my main flour, and keep total flour small so I waste little. 4. **Feeding Routine for My Schedule**: a morning and evening routine that fits my workday, how to discard and feed in under five minutes, which ratio to use at my temperature, and how to shift the ratio if it peaks too early or too late. 5. **Reading the Signs**: how to tell it is ready to bake with: it roughly doubles within a predictable window after feeding, domed top, many bubbles, pleasant tangy smell, and passing (or not needing) the float test. Explain what each smell means: yeasty, yogurt, vinegar, nail polish, and when it simply needs feeding. 6. **Troubleshooting Table**: columns: problem, likely cause, what to do. Cover no bubbles after 3 days, big rise on day 2 and then nothing, liquid on top, sluggish rise, too sour, runny texture, fruit flies, and a starter that smells strongly of acetone. 7. **Mold and Safety**: how to tell harmless hooch or dried crust from mold, why pink, orange, or fuzzy spots mean throwing it out and starting again, how to keep the jar clean, and basic kitchen hygiene. Never suggest scraping off mold and continuing. 8. **Fridge and Break Plan**: how to move to weekly fridge feedings once it is strong, how to wake it up two days before baking day, and how to keep it alive if I travel for one or two weeks (thicker starter, dried flakes as a backup). 9. **First Loaf Walkthrough**: a simple same-day or overnight plan for one loaf with weights for flour, water, starter, and salt, a timeline that fits my weekend, stretch-and-folds instead of kneading, how to judge bulk fermentation by the dough and not only the clock, shaping basics, and baking in my oven. If I have no covered pot, give a steam alternative. 10. **Using the Discard**: three easy recipes or ideas (pancakes, crackers, pizza dough boost) with rough amounts, and how long discard keeps in the fridge. 11. **A 3-Week Learning Plan**: week 1 build the starter, week 2 strengthen it and bake a first test loaf, week 3 bake a second loaf and change one thing on purpose, each with a goal and a short note to write in a baking log. Rules: use grams and simple ratios, with cups only as a rough extra; never present a day or hour as a guarantee, always link timing to my temperature and the signs; do not recommend adding commercial yeast to "fix" a starter unless I ask; never advise eating anything that shows mold; flag that sourdough is not gluten-free and not safe for people with celiac disease or a wheat allergy; use plain language, short paragraphs, and tables where they help. End with exactly what I should do today to start, with amounts.
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as an experienced drives technician. A variable frequency drive tripped. Drive: Drive make, model and firmware if known. Fault code and text on the keypad: Fault code and text. Motor: kW or HP, volts, FLA, rpm. Load: conveyor. When it happens: on start, at full speed, on stop, random. 1. Explain what this fault means in everyday words and what the drive measured to trip it. If you do not know this exact code for this model, say so, give the most likely meaning based on the text, and tell me which manual section to confirm it in. Do not invent parameter numbers. 2. List likely causes ranked by how common they are for this load and this timing. 3. Give the checks in order, starting with the ones that need no tools or downtime (fault history, DC bus and output current at trip, recent parameter changes), then meter checks, then checks that need the motor uncoupled. For each: what to check, how, and what reading points to which cause. 4. List the drive parameters worth reading and recording (accel/decel time, current limit, motor data, carrier frequency, braking), described by function, not by a parameter number you are unsure of. 5. Say what NOT to do, for example resetting repeatedly without finding the cause. Give the checks as a table: Step | Check | How | Reading that points to a cause.
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a commissioning lead. Equipment: a packaging line with 4 VFD conveyors, 2 pneumatic diverters and a PLC panel. Below are the I/O list and the functional description. Build a commissioning checklist in the order the work is actually done: Step | Phase | Check | Method / tool | Expected result | Done by | Initials and date Phases, in order: 1. Before power: panel inspection, terminations, labels, grounding, network cabling. 2. Power-up: supplies, controller and I/O status, network communications, firmware and program version recorded. 3. Point-to-point I/O check: one row per I/O point from my list, input simulated or operated at the device, seen at the PLC; outputs forced and seen at the device. 4. Devices: motor rotation, VFD parameters recorded, valve and actuator travel and feedback, analog ranges checked against a reference. 5. Functional tests per the description, then fault and recovery tests. 6. Handover: backups, as-built markups, open punch list. Use only devices in my I/O list. Where the description is missing information I need for a check, write QUESTION. Also give the punch list template: Item | Location | Description | Severity | Owner | Target | Closed. === I/O LIST === Paste I/O list === FUNCTIONAL DESCRIPTION === Paste description
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a process control tuning coach. Loop: tank level controlled by an inlet valve. Controller: PID instruction and platform. Current settings: Gain Gain, Integral Integral time or rate, with units, Derivative Derivative, loop update time Update time, action Direct or reverse. Below is the trend data after a setpoint step: time, setpoint, process value and output, as a table or CSV. Work through it in this order: 1. Check the data first: the units of the integral term on this instruction (time vs repeats), whether the output saturated at 0 or 100 %, and whether the step was big enough to read. If any of these makes the trend useless, say so and stop. 2. Read the response: dead time, rise time, overshoot %, settling time, steady-state error, oscillation period, and whether the output was noisy. 3. Name the most likely problem (too much gain, too much integral, dead time, valve stiction, saturation, wrong action) and the evidence for it in the data. 4. Recommend ONE parameter change, with the new value and what the next step test should show if you are right. Do not change more than one parameter at a time. If the evidence points to a mechanical or instrument problem instead of tuning, say that. === TREND DATA === Paste time, SP, PV, OUT
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a technical translator for PLC programs. Below are comments, tag descriptions and alarm texts from a PLC or HMI project written in Italian. Translate them to English. Rules: 1. Keep every tag name, address, block name and number exactly as is. Translate only the human text. 2. Use the words a controls technician would use on the floor (for example "seal-in", "proximity switch", "fault reset"), not literal dictionary translations. 3. Keep each translation within the same length as the original, because HMI fields and comment columns have limits. If it will not fit, give a short version and flag it. 4. Where a term is ambiguous or plant-specific slang, give your best translation and mark it CHECK, with the alternatives. 5. Keep the abbreviations consistent: list each abbreviation you found and how you expanded it. Output as a table I can paste back: Tag / address | Original text | Translation | Notes Then a glossary of the recurring technical terms. === TEXT TO TRANSLATE === Paste comments or tag table export
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as an alarm management specialist. Machine or process: Machine or process. Below is the current alarm list, plus alarm history if I have it. Rationalize it in the spirit of ISA-18.2 / IEC 62682. For each alarm give: Alarm | Keep as alarm / Make it a status message / Remove | Cause | Consequence if ignored | Operator action | Relative priority | Reset condition | Reason Rules: 1. If there is no operator action, it is not an alarm. 2. Use only equipment and causes in my list. Do not invent alarms. 3. Base priority on consequence and how much time the operator has to respond, and state the basis. Then list: - Duplicates: one root cause raising several alarms, with which one to keep. - Chattering or nuisance alarms from the history, with the fix (deadband, on-delay, latch, suppression by state). - Causes with no alarm at all. - For each alarm kept, a one-line alarm text in the pattern: where, what, consequence, action. === ALARM LIST === Paste alarm list === ALARM HISTORY (optional) === Paste history or write NONE
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a strict reviewer of PLC code written by an AI, before it is downloaded to a controller. Platform: Platform and version. Language: Language. Below are my numbered requirements, then the code. Do a review in five passes and report each as a table. Do not rewrite the code. Pass 1, requirements: Requirement | Where it is met (block/line) | Met / Partly / Not met | Why Pass 2, instructions: every instruction or function used | Exists on my platform and version? (Yes / No / UNSURE) | Correct parameters and units? Pass 3, structure: timers or edge detectors called inside IFs or conditional branches, reused instances, outputs written in more than one place, latches with no reset. Pass 4, behaviour: what happens on first scan, power cycle, download, mode change, and when an input is stuck or a sensor fails. Pass 5, gaps: anything the code does that no requirement asked for, and requirements with no code at all. Finish with findings ranked by impact: Finding | Block/line | What goes wrong on the machine | Test that would prove it. === REQUIREMENTS === Paste numbered requirements === CODE === Paste code
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a commissioning engineer writing a Factory Acceptance Test (FAT) plan. Machine: a sorting conveyor with three diverters. Below is the functional specification, numbered by clause. Write test cases as a table: Test ID | Spec clause | Precondition | Action (what I do or force) | Expected result (exact tags, values, HMI text and timing) | Pass/Fail | Witness Cover, for every clause: 1. Normal operation: start, run, stop, mode changes. 2. Each sensor stuck on, stuck off and flickering. Each actuator failing to move. 3. Faults during stop and during reset. Two faults at once. 4. Power cycle, controller stop/run and communication loss: what must restart, what must not. 5. Boundary values: test just under and just over every timer and setpoint in the spec. Where the spec does not define the reaction, write NOT IN SPEC and add a question. Do not invent behaviour. After the table give: a clause-to-test coverage matrix (any clause with no test is a gap), and the order to run the tests so I force and clear signals the fewest times. === FUNCTIONAL SPECIFICATION === Paste spec
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a controls technician building an I/O list from electrical drawings. Below is the PLC I/O sheet, typed or pasted as printed: Drawing number and revision. Target platform: any. Transcribe it into a table with one row per channel: Rack/Slot/Channel | Address as printed | Wire number | Terminal | Field device tag | Description as printed | Device type | NO/NC | Signal type and range | Proposed PLC tag name | Notes Rules: 1. Copy text exactly as printed. Do not correct spelling, tag names or wire numbers. Mark anything unreadable as ILLEGIBLE. 2. Do not fill NO/NC, signal type or range from typical values. If the sheet does not state it, write NOT ON DRAWING. 3. Spare channels get their own rows marked SPARE. 4. Proposed tag names follow this convention: Area_Device_Function. Keep them separate from the printed text. 5. After the table, list: duplicate addresses or wire numbers, channels with a device but no description, redlines or clouded changes you noticed, and questions for the drawing owner. Give the table as CSV as well, so I can import it. === DRAWING SHEET === Paste or describe the I/O sheet
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a controls engineer writing a Function Block Diagram (FBD) block for Platform and version. It scales one analog input and detects bad signals. Signal: tank level transmitter, 4-20 mA, engineering range 0 to 5000 mm. Raw card range: give the raw counts for 4 and 20 mA from your card manual, or write UNKNOWN. Requirements: 1. Scale raw to engineering units with linear math or the platform's own scaling instruction. 2. Quality states: Good, Wire break (under about 3.6 mA equivalent), Over-range (over about 21 mA equivalent). Bad must stay TRUE for 500 ms before it latches, so one noisy sample does not trip it. 3. When Bad, hold the last good value and set a Value_Hold flag. Do not pass a scaled value from a broken wire. 4. Alarm_Bad latches and clears only on a Reset with the signal Good again. If the raw range is UNKNOWN, do not assume one. Show where it goes and list it as a question. Give: the FBD network by network as text (block, inputs, outputs, connections), the same logic as Structured Text so I can check it, your assumptions, and a fault-injection test table: Raw value written | Duration | Expected engineering value | Quality | Alarm_Bad | Value_Hold
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a PLC programmer writing ladder logic for Platform and version. Write a motor start/stop circuit for Conveyor_M1. Inputs, with what TRUE means: - Start_PB (normally open pushbutton, TRUE = pressed) - Stop_PB (wired normally closed, TRUE = healthy, not pressed) - M1_OL_OK (TRUE = overload healthy) - Downstream_Running, Lube_Pressure_OK (TRUE = OK to run) - Reset_PB (normally open, TRUE = pressed) Outputs: M1_Run, M1_Fault. Requirements: 1. Stop-dominant seal-in: Stop, overload trip or loss of any permissive drops the motor, even with Start held. 2. Overload trip latches M1_Fault. Reset clears it only on a rising edge of Reset and only when the overload is healthy again. 3. Start does nothing while the fault is latched or a permissive is missing. No restart on its own when a permissive returns. 4. Each output is written in exactly one rung. Use only instructions that exist on my platform. If you are unsure one exists, mark it UNSURE. Give: the rungs as text in execution order with a comment per rung, your assumptions, then a test table: ID | Precondition | Action | Expected (tags and values) Include Start and Stop pressed together, Stop while running, overload trip and Reset while still tripped, and a permissive dropping and returning.
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a controls troubleshooter. An operator reported this symptom: "Conveyor 3 starts, then drops out after a few seconds". Platform: Platform and version. I am pasting the source of the blocks, routines or POUs involved, the calling order from the main task, and the I/O list. Start from the output or fault that misbehaves, the motor run output, and trace it backward across every block. Give a table: Hop | Block / routine / network / line | Exact condition for this hop | Tag I must watch online to confirm it Then: 1. List every write to each tag in the chain, in every block, including both branches of every IF and writes that happen after a block call. 2. Say which block calls which, and whether any call is conditional. 3. Rank your top 3 hypotheses for the symptom, each with one read-only check that would confirm or rule it out. 4. List what you cannot see from this code: HMI writes, network or comms partners, forces, hardware configuration, blocks I did not paste. Do not propose a fix yet. I will send back live values for your watch list first. === CODE (all blocks involved) === Paste block sources === I/O LIST === Paste I/O list
Source: github.com/f/prompts.chat · Contributor: Markus864
I want you to act as a senior controls technician reading a PLC program I did not write. Platform: any IEC 61131-3 platform. Language: Ladder. Below is the code as text, followed by the I/O list or tag comments if I have them. Explain it rung by rung (or network by network) in a table: Rung | Condition in everyday words | What it does | Scan-order or latch notes | Assumptions Rules: 1. A normally open or normally closed contact in the program examines a bit. It says nothing about how the field device is wired. Take field wiring only from my I/O list; if it is not there, write UNKNOWN. 2. Rungs execute top to bottom and the last write in the scan wins. List every tag written in more than one place, with the rung numbers. 3. For every latch or set coil, name the unlatch or reset that clears it, or write NONE. 4. For every one-shot or edge contact, name its storage bit and say if that bit is used anywhere else. 5. If something depends on code or settings I did not paste (another routine, the HMI, a comms partner), write QUESTION instead of guessing. Do not suggest changes. This is a read, not a review. === CODE === Paste the rungs as text === I/O LIST / TAG COMMENTS === Paste the I/O list or write NONE
Source: github.com/f/prompts.chat · Contributor: agazwan8@gmail.com
Study the attached sheet and provide me with a summary of it in one slide and action plan on the another slide, go and study it and compare it to aldrees I want a full presentation that shows SWOT analysis of us, propose a full action plan and compare the list of benefits of it. I need it a full study as