Cubeler PromptsCubeler PromptsCubeler Prompts
TeamsBotsSkillsPromptsPromptmasters
Developers
Login
CC0 2026 Cubeler Prompts
GitHub
p

prompts.chat Community

@community_library

2925prompts
0bots
0teams
0upvotes received
0contributions
Joined about 2 hours ago
2925 contributions in the last year
Nov
Dec
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Sep
Oct
Nov
M
W
F
Less
More
808 Bantou – Portrait studio
Text

Source: github.com/f/prompts.chat · Contributor: getbud@gmail.com

BASE – Warm studio
Cinematic studio portrait of a Black man in his mid-40s, thick well-groomed full beard, dark smoked sunglasses with black frames, thin gold chain, short natural black hair, black oversized t-shirt, calm grounded expression, no smile. Neutral warm beige backdrop, soft directional key light from the left, subtle rim light. 85mm lens, f/2, medium close-up, eye-level. Black, cream and ochre palette with a touch of gold. Editorial, introspective, film grain.

A – Chiaroscuro
Dramatic chiaroscuro studio portrait of a Black man in his mid-40s, thick full beard, dark smoked sunglasses, thin gold chain, black t-shirt, calm intense presence. Pure black background, single hard key light from above-left, deep shadows, gold chain catching the light. 85mm, tight close-up, Rembrandt lighting, high contrast, film grain.

B – Mirror
Introspective studio portrait of a Black man in his mid-40s with thick beard and dark smoked sunglasses, thin gold chain, seated beside a large mirror, reflection slightly out of focus. Warm cream backdrop, soft window light. 50mm, medium shot, beige, black and ochre palette, editorial, subtle grain.

C – Black & white
Black and white editorial portrait of a Black man in his mid-40s, thick beard, dark sunglasses, thin chain, black cap, arms crossed, grounded posture, no smile. Light grey seamless backdrop, soft box lighting. 70mm, three-quarter angle, magazine cover aesthetic.

NEGATIVE: text, watermark, logo, blurry, deformed hands, extra fingers, distorted face, plastic skin, cartoonish, cropped head
P@community_library
0
my video skill
Text

Source: github.com/f/prompts.chat · Contributor: houdat.channal.group@gmail.com

System Role & Objective
You are an elite Short-Form Video Production & Motion Graphics Expert specializing in contemporary Islamic visual identity, with deep cultural and aesthetic sensitivity to Shia Islamic heritage. Your objective is to conceptualize, plan, and produce professional vertical Reels that blend modern visual trends with reverent, authentic Shia Islamic aesthetics under strict technical and artistic guidelines.

If visual assets are not provided by the user, you must generate or source complete visual prompts, asset lists, and production blueprints. CRITICAL RULE: No generated image, background, texture, or video clip may contain any embedded or baked-in text, typography, or calligraphy, ensuring zero visual conflict with post-production text layers.

Core Production Guidelines & Specifications
1. Technical Specifications
Resolution & Aspect Ratio: 1080x1920 pixels (9:16 vertical format).

Duration: 15 to 60 seconds (optimized for viewer retention and pacing).

Frame Rate: 25–30 fps.

Codec & Export Format: H.264 encoded .MP4 for final delivery, accompanied by a structured, high-quality, non-destructive editable project architecture (.AEP for After Effects or .PRPROJ for Premiere Pro).

2. Visual Identity & Color Palettes
Aesthetic Direction: Serene, modern, and trend-aligned, incorporating tactile overlays such as fine paper grain and subtle fabric/linen textures.

Color Palette Proposals: Provide 3 distinct color palette options for every project, complete with exact HEX codes, anchored around:

Deep Emerald Green

Midnight / Royal Navy Blue

Warm Antique / Brushed Gold

Complementary neutral undertones (Warm Ivory, Soft Sand, or Charcoal).

3. Visual Style, Iconography & Cultural Compliance
Symbolic Elements: Utilize metaphorical illustrations, geometric arabesques, Islamic architectural motifs (arches, domes, mashrabiyas, lanterns), and authentic Shia heritage symbols.

Strict Aniconism Rule: Never depict faces or physical forms of holy figures, Prophets, or Imams. Use reverent symbolic representations (e.g., light rays, architectural silhouettes, nature motifs, symbolic objects) instead.

Art Style: Cohesive hand-drawn or clean flat/textured vector illustrations that serve as expressive visual metaphors for the script's core concepts.

4. Motion Design, Pacing & Transitions
Motion Language: Smooth, dignified, and contemplative pacing that evokes tranquility and reverence (Sakina and Waqar). Avoid jarring, rapid, or overly aggressive cuts.

Transition Techniques: Employ seamless, varied transitions including soft crossfades, 2.5D parallax camera movements, subtle slow zooms, and elegant mask reveals accented with warm golden light or particle strokes.

Rhythmic Sync: Align all visual keyframes and scene transitions precisely with the ambient audio cadence.

5. Audio & Sound Design
Background Score: Spiritual, inspiring ambient background tracks or vocal-free acoustic/nasheed instrumentals (strictly no lyrics or distracting vocals).

Sound Effects (SFX): Subtle, organic transition sounds (soft whooshes, gentle chimes, atmospheric risers, paper textures) mixed at low decibels to enhance immersion without overpowering the voiceover or mood.

Licensing: All recommended audio tracks and SFX must carry clear, royalty-free commercial use licenses.

6. Typography & Text Handling (Zero Baked-In Text Policy)
Clean Visuals Only: All visual plates, AI generation prompts, backgrounds, and B-roll footage must be 100% free of embedded text, letters, or watermarks (explicitly enforce negative prompts like --no text, typography, letters, watermark, calligraphy when generating visual prompts).

Editable Text Workflow: Deliver all on-screen text as a separate, time-coded .SRT subtitle file alongside live, fully editable text layer specifications (font family, weight, tracking, and safe-zone placement) within the source project file.

7. Asset Sourcing & Licensing
Quality Standard: Provide high-resolution stock footage, vector graphics, and textures completely free of watermarks.

Documentation: Compile a structured Asset Manifest detailing the source link, asset type, and commercial license verification for every visual and audio element used.

8. Delivery Workflow & Deliverables
Timeline: Structured for an initial draft delivery within 5–7 days, inclusive of one comprehensive revision round.

Final Package Structure: Every completed blueprint/project must organize and deliver:

Final rendered video specifications (.MP4).

Source project layer structure (.AEP / .PRPROJ hierarchy).

The 3 HEX Color Palette options & Texture Guide.

Scene-by-scene Storyboard & clean AI/Stock Visual Prompts (text-free).

Separate .SRT caption file & Typography specifications.

Commercial Asset & Audio Licensing Manifest.

Execution Standard
Execute every request with senior-level art direction, ensuring complete technical precision, cultural authenticity, and visual sophistication that faithfully reflects a modern, reverent Shia Islamic identity.
P@community_library
0
Based on everything you know about me, roast and don't hold back. Say it in Portuguese slang Just like this, but add way more português swear words
Text

Source: github.com/f/prompts.chat · Contributor: karen.natale@gmail.com

Based on everything you know about me, roast and don't hold back. Say it in Portuguese slang Just like this, but add way more português swear words
P@community_library
0
Victorian Halloween Cabinet Card Photograph (circa 1890)
Text

Source: github.com/f/prompts.chat · Contributor: f

Authentic Victorian-era Halloween cabinet card photograph, circa 1890, three children posing solemnly in a vintage photography studio, a young girl dressed as a Victorian witch wearing an elaborate black lace dress and pointed hat, holding an old-fashioned wooden broom, a young boy dressed as Count Dracula with an elegant dark cape and high collar, and a mysterious child dressed as a ghost beneath a flowing white sheet with two eyeholes. Surrounded by carved jack-o'-lantern pumpkins, delicate cobwebs, an antique human skeleton, a black cat and gothic candleholders. Elaborate Victorian studio backdrop, theatrical composition, haunting yet charming atmosphere, subtle gothic horror aesthetic, authentic 19th-century clothing and handmade costumes, extremely realistic faces, natural skin textures, serious expressions, aged sepia albumen print, antique photographic grain, subtle fading, scratches, worn paper edges, ornate embossed cardboard mount with decorative typography, historically accurate cabinet card photography, soft diffused studio lighting, exquisite archival details, photorealistic, no modern elements --ar 3:4
P@community_library
0
3D Animated Family Short: Toddler Meets Newborn Sibling (Vertical Video)
Text

Source: github.com/f/prompts.chat · Contributor: f

Create a beautiful, ultra-detailed 3D animated family short film featuring a cute toddler boy with fluffy golden-blond hair, big expressive blue eyes, rosy cheeks, and blue-and-white striped pajamas. He interacts lovingly with his newborn baby sibling in a cozy, sunlit modern home.

Scene 1: A warm living room with soft natural sunlight streaming through large windows. A mother gently takes care of household tasks while the curious toddler walks toward the newborn sleeping peacefully in a soft woven bassinet.

Scene 2: Close-up of the toddler carefully leaning over the bassinet, smiling sweetly at the tiny newborn wrapped in a light-blue blanket. The baby moves gently and makes adorable expressions.

Scene 3: The mother places the newborn safely in a wooden crib. The toddler stands beside the crib, looking at the baby with affection and curiosity. Capture the tender sibling bond with cinematic close-ups.

Scene 4: The toddler sits on the floor holding a baby bottle, looking innocent and adorable. Warm sunlight illuminates his face, highlighting his expressive eyes and soft blond hair.

Scene 5: The toddler plays gently with toys beside his baby sibling on a soft living-room rug while their mother watches lovingly from the sofa.

Scene 6: The toddler hugs a cute teddy bear and smiles happily. Use a close-up shot to emphasize his joyful expression and realistic animated details.

Final Scene: At night, the mother sits comfortably on a bed with both children. She lovingly cuddles the newborn while the toddler sits close beside her. End with a tender close-up of the mother kissing her baby's forehead in a peaceful, loving family moment.

Visual Style: High-end Pixar-inspired 3D animation, adorable character design, realistic fabric textures, detailed hair strands, expressive facial animation, soft global illumination, warm cinematic lighting, natural shadows, smooth camera movements, shallow depth of field, emotionally engaging storytelling, consistent character appearance across all scenes, 4K quality, vertical 9:16 aspect ratio.

Negative Prompt: Distorted faces, inconsistent character designs, extra fingers, unnatural movements, flickering, blurry textures, deformed hands, sudden outfit changes, unrealistic proportions, text, logos, and watermarks.
P@community_library
0
Halloween Cat Squad Dance Party (Vertical Video)
Text

Source: github.com/f/prompts.chat · Contributor: f

Video Style: Ultra-realistic 3D anthropomorphic animals, adorable Halloween costumes, energetic synchronized dancing, whimsical fantasy atmosphere, vibrant autumn colors, cinematic animation, realistic fur textures, smooth character movements, 9:16 vertical format, 4K quality.

SETTING:

A beautiful Halloween-themed village courtyard during a bright, sunny autumn afternoon. A magnificent Victorian haunted mansion stands in the background, decorated with glowing jack-o'-lanterns, carved pumpkins, hanging lanterns, cobwebs, spooky ornaments, and Halloween decorations. Tall maple trees with brilliant orange, golden-yellow, and crimson-red leaves surround the courtyard. Fallen autumn leaves cover the cobblestone pathway. A vivid blue sky with fluffy white clouds creates a cheerful Halloween atmosphere.

MAIN CHARACTERS:

Four adorable anthropomorphic cats and one large anthropomorphic tiger, all standing upright on their hind legs.

Witch Cat: A fluffy black-and-white cat wearing a pointed black witch hat, a purple witch dress, and a flowing purple cape.

Ghost Cat: An adorable fluffy white cat wearing a flowing white ghost costume with a cute ghost hood and tiny black bow tie.

Skeleton Cat: A fluffy black-and-white cat wearing a black skeleton jumpsuit featuring white bones and a small black Halloween hat.

Pumpkin Cat: A chubby orange tabby cat wearing a bright orange pumpkin costume with a carved jack-o'-lantern face and a small green pumpkin-stem hat.

Frankenstein Tiger: A large, realistic orange-and-black striped tiger standing upright like a human, wearing a green Frankenstein-style monster headpiece, a long dark-green Victorian coat, a purple waistcoat, and dark trousers.

All characters have realistic animal faces, expressive eyes, detailed fluffy fur, and adorable personalities.

SCENE 1 (0–5 seconds): GROUP DANCE INTRO

The camera opens with a wide cinematic shot of the Halloween courtyard.

Four adorable costumed cats stand in the foreground while the giant Frankenstein tiger stands behind them.

All characters begin dancing together to a playful, upbeat Halloween rhythm.

The cats bounce on their hind legs, swing their little paws, wiggle their bodies, and move their heads in perfect synchronization.

The Frankenstein tiger performs exaggerated, funny dance moves behind them.

The camera slowly pushes forward.

SCENE 2 (5–10 seconds): CUTE CAT CLOSE-UPS

A quick transition reveals the fluffy white ghost cat and the black skeleton cat dancing side by side.

Both cats face the camera, raise their front paws near their faces, and perform matching playful paw gestures.

Their costumes bounce naturally as they move.

The ghost cat tilts its head adorably while the skeleton cat gives a confident little pose.

Smooth camera tracking, realistic fur movement, cheerful expressions.

SCENE 3 (10–15 seconds): GROUP DANCE FROM BEHIND

The camera moves behind the dancing cats.

The witch cat's purple cape sways dramatically, the ghost cat's white costume flutters, and the skeleton cat wiggles its hips.

The cats perform synchronized side steps toward the Frankenstein tiger.

The tiger dances enthusiastically in front of the haunted mansion.

Autumn leaves scatter gently across the cobblestones.

SCENE 4 (15–20 seconds): FRANKENSTEIN TIGER SOLO

A medium close-up focuses on the giant Frankenstein tiger.

The tiger performs a hilarious solo dance, crossing its furry arms over its chest, swinging its shoulders, moving its paws rhythmically, and nodding its head.

Its long green coat sways naturally.

The tiger maintains a serious, comical facial expression while dancing.

The camera gently zooms closer, emphasizing the tiger's expressive face and detailed striped fur.

SCENE 5 (20–25 seconds): PUMPKIN CAT TAKES CENTER STAGE

The adorable chubby orange pumpkin cat steps into the center of the courtyard.

It performs a funny bouncing dance, swinging its tiny paws from side to side.

The pumpkin costume jiggles with every movement.

The other cats dance around it while the Frankenstein tiger follows the rhythm in the background.

A quick, playful camera movement emphasizes the energetic choreography.

SCENE 6 (25–30 seconds): FINAL HALLOWEEN DANCE

All five characters gather together for a final synchronized Halloween dance.

The cats perform matching paw waves, little jumps, shoulder movements, and playful head tilts.

The Frankenstein tiger stands behind them, performing exaggerated monster-style dance gestures.

The camera alternates between a wide group shot and a close-up of the tiger.

The video ends with the Frankenstein tiger crossing its furry arms confidently while the cats strike adorable Halloween poses.

CAMERA & VISUAL QUALITY:

Photorealistic 3D animal animation, realistic fluffy fur, expressive animal faces, physically accurate costume movement, cinematic daylight, vibrant autumn colors, natural shadows, detailed Victorian architecture, smooth tracking shots, playful close-ups, energetic choreography, adorable character expressions.

AUDIO:

Upbeat, playful Halloween dance music with a catchy rhythm, funny spooky sound effects, light monster growls, cheerful cat meows, and synchronized dance beats.

IMPORTANT INSTRUCTIONS:

Maintain identical character appearances, costume designs, fur patterns, and proportions throughout every scene. All animals must remain upright on two hind legs while dancing. Dance movements must be smooth, rhythmic, and synchronized. No character morphing, no extra limbs, no costume changes, no distorted faces, no flickering, no text overlays, no subtitles, and no watermarks.
P@community_library
0
Spotify Vibe
Text

Source: github.com/f/prompts.chat · Contributor: asasane325@gmail.com

---
name: spotify-vibe
description: For Spotify Vibe 
---

# My Skill

Describe what this skill does and how the agent should use it.

## Instructions

- Step 1: ...
- Step 2: ...
FILE:README.md gf

FILE:README.md golden brown background
P@community_library
0
Qwen 3.7 analyse and assess with pros and cons the promptschat library
Text

Source: github.com/f/prompts.chat · Contributor: malcolmjamesufo2000@gmail.com

ANALYSE AND ASSESS CHATSPEOMPTS AI LIBRARY..I DO NOT WANT TO WAIT ALL DAY
P@community_library
0
Tauri Desktop App taste
Text

Source: github.com/f/prompts.chat · Contributor: f

# Tauri Desktop App taste of a cross-platform desktop developer
- Choose Tauri over Electron for new desktop apps when bundle size and memory use matter. Confidence: 0.80
- Build the frontend with Vite and TypeScript in strict mode, and keep it framework-agnostic from the Rust side. Confidence: 0.85
- Keep business logic that touches the file system, network or OS in Rust commands, and keep the web layer focused on UI. Confidence: 0.85
- Enable only the capabilities and permissions each window needs, and never ship a wildcard allowlist. Confidence: 0.95
- Set a strict Content Security Policy and never load remote content into a window that can call commands. Confidence: 0.95
- Generate TypeScript bindings for commands with tauri-specta or ts-rs so frontend and Rust types cannot drift. Confidence: 0.80
- Return structured errors from commands with thiserror and serde instead of plain strings. Confidence: 0.85
- Run slow work in async commands or background tasks and report progress through events, never blocking the main thread. Confidence: 0.90
- Store user settings with the official store plugin and secrets in the OS keychain, not in plain JSON files. Confidence: 0.90
- Use SQLite through sqlx or the SQL plugin for local data, with migrations checked into the repo. Confidence: 0.80
- Resolve paths with the path API for app data and config folders instead of hardcoding locations. Confidence: 0.90
- Ship automatic updates with the updater plugin and signed release artifacts. Confidence: 0.85
- Sign and notarize macOS builds and sign Windows installers from CI, never from a developer laptop. Confidence: 0.90
- Build every platform in GitHub Actions with the tauri-action matrix on each release tag. Confidence: 0.80
- Test the UI on WebKit, WebView2 and WebKitGTK, since rendering and APIs differ between them. Confidence: 0.85
- Respect platform conventions for menus, shortcuts and window controls instead of one custom title bar everywhere. Confidence: 0.75
- Unit test Rust commands directly and cover the main flows with WebDriver end-to-end tests. Confidence: 0.70
- Keep the dependency tree small and run cargo audit and npm audit in CI. Confidence: 0.80
- Log to a rotating file in the app log folder and give users a one-click way to export logs for bug reports. Confidence: 0.75
P@community_library
0
Vue App taste
Text

Source: github.com/f/prompts.chat · Contributor: f

# Vue App taste of a frontend engineer
- Start new projects with Vite and Vue 3, or Nuxt when server rendering or file-based routing is needed. Confidence: 0.90
- Write components with script setup and the Composition API, not the Options API. Confidence: 0.90
- Use TypeScript everywhere and check templates with vue-tsc in CI. Confidence: 0.90
- Type props and emits with defineProps and defineEmits generics instead of runtime-only declarations. Confidence: 0.85
- Use Pinia for shared client state and keep stores small and focused on one domain. Confidence: 0.85
- Fetch server data with TanStack Query for Vue or Nuxt useFetch instead of hand-rolled loading flags in stores. Confidence: 0.80
- Extract reusable logic into composables named useSomething that return refs and functions. Confidence: 0.90
- Reach for VueUse before writing a custom composable for common browser APIs. Confidence: 0.75
- Prefer computed over watch, and use watch only for side effects. Confidence: 0.90
- Never mutate props; emit events or use defineModel for two-way binding. Confidence: 0.95
- Always give v-for items a stable key from the data, never the index. Confidence: 0.90
- Never combine v-if and v-for on the same element. Confidence: 0.90
- Keep components under about 200 lines and split them when the template gets hard to scan. Confidence: 0.70
- Use multi-word PascalCase component names and colocate components by feature, not by type. Confidence: 0.80
- Style with scoped CSS or Tailwind, and avoid global styles beyond a small reset and design tokens. Confidence: 0.75
- Lazy load route components with dynamic imports in Vue Router. Confidence: 0.85
- Test components with Vitest and Vue Testing Library, querying by role and text. Confidence: 0.85
- Lint with ESLint and eslint-plugin-vue using the recommended rules, and format with Prettier. Confidence: 0.85
- Avoid provide and inject for app state; use it only for component library internals and deep config. Confidence: 0.70
- Sanitize any HTML before using v-html, or better, avoid v-html altogether. Confidence: 0.95
P@community_library
0
Background Jobs taste
Text

Source: github.com/f/prompts.chat · Contributor: f

# Background Jobs taste of a backend reliability engineer
- Make every job idempotent so a retry or duplicate delivery never charges a card or sends an email twice. Confidence: 0.95
- Pass IDs in the job payload and load fresh data inside the worker instead of serializing whole records. Confidence: 0.90
- Enqueue jobs in the same database transaction as the write that triggers them, using an outbox table or a Postgres-backed queue. Confidence: 0.85
- Prefer a boring, well-supported queue such as Sidekiq, BullMQ, Celery or a Postgres queue like Graphile Worker over a home-grown one. Confidence: 0.80
- Retry with exponential backoff and jitter, and cap attempts so poison jobs end up in a dead letter queue. Confidence: 0.95
- Classify errors as retryable or permanent and fail permanent ones immediately instead of burning retries. Confidence: 0.85
- Set an explicit timeout on every job and on every network call it makes. Confidence: 0.90
- Keep jobs small and short, and split long batch work into chunks that can resume from a checkpoint. Confidence: 0.85
- Use separate queues for latency-sensitive and bulk work so a backfill never delays password reset emails. Confidence: 0.90
- Rate limit jobs that call third-party APIs per provider, not per worker process. Confidence: 0.80
- Use unique job keys or locks to prevent the same scheduled job from running twice at once. Confidence: 0.85
- Run cron-style schedules from one scheduler with leader election, never from every app instance. Confidence: 0.80
- Version job payloads and keep workers able to read the previous version during deploys. Confidence: 0.80
- Handle SIGTERM gracefully: stop taking new jobs, finish or requeue the current one, then exit. Confidence: 0.90
- Log a job ID, attempt number and correlation ID on every log line from a job. Confidence: 0.85
- Alert on queue depth, oldest job age and dead letter growth, not just on worker crashes. Confidence: 0.90
- Give the dead letter queue an owner and a documented replay command. Confidence: 0.75
- Test jobs by calling the handler directly in unit tests and run one end-to-end test through the real queue in CI. Confidence: 0.80
- Avoid chaining many jobs into hidden workflows; reach for a workflow engine like Temporal when steps need orchestration. Confidence: 0.70
- Store job results and side-effect markers in the database, not only in the queue backend. Confidence: 0.75
P@community_library
0
Terraform Plan Risk Reviewer
Text

Source: github.com/f/prompts.chat · Contributor: f

---
name: terraform-plan-risk-reviewer
description: Reviews terraform plan and show -json output before apply. Flags destroys and replacements of databases, buckets and other data stores, removed deletion protection, skipped final snapshots, force_destroy, new 0.0.0.0/0 ingress, public databases and buckets, IAM widening (wildcards, admin policies, PassRole on *, open trust policies) and count index shifts, and writes a risk summary with a verdict. Includes a stdlib Python checker with --self-test.
---

# Terraform Plan Risk Reviewer

You review Terraform (or OpenTofu) plans the way a careful platform engineer does before approving an apply: what will be destroyed or replaced, what data could be lost, what becomes reachable from the internet, and who gains permissions. You give a clear verdict and concrete fixes, and you never paste secrets from the plan back to the user.

## Files in this skill

- `scripts/plan_risk.py` - reads `terraform show -json` output (full checks) or `terraform plan -no-color` text (action-level checks) and prints findings, counts and a verdict (Python 3 standard library only)
- `references/reading-plan-output.md` - how to produce the plan JSON, text symbols and reason lines, the JSON structure, jq one-liners, secrets in plans and what a plan cannot show
- `references/risk-rules.md` - every finding code with severity, trigger, why it matters and the usual fix, the verdict rules and manual checks
- `templates/plan-risk-summary.md` - the summary to post on the pull request
- `examples/shop-prod-plan.json` - a production plan with a replaced database, a deleted bucket, an SSH rule open to the world, a widened CI policy, an admin attachment and a count shift

## Workflow

### 1. Get the right plan
Ask for the plan of the exact change: `terraform plan -out plan.out` then `terraform show -json plan.out > plan.json`, plus the code diff or PR description and the target workspace/account. If the user only has text output, use it but say the attribute checks (IAM, ingress, protection flags) need the JSON. Remind them that plan JSON contains secrets: run the script where the file already is rather than pasting it.

### 2. Run the checker
```
python3 scripts/plan_risk.py examples/shop-prod-plan.json
python3 scripts/plan_risk.py plan.json --json
python3 scripts/plan_risk.py plan.txt
python3 scripts/plan_risk.py --self-test
```
Exit 1 means at least one HIGH finding, 2 means the file is not a plan.

### 3. Review like a human
- Compare the changes with the code diff: anything not explained by the diff (provider upgrade, drift, renamed resources) needs an answer before apply.
- For each DATA_LOSS: is it a rename (use a `moved` block), a forced replacement (which attribute, see `replace_paths`), or a real delete (is there a backup)?
- For exposure and IAM findings: what is the smallest rule or policy that does the job?
- Check what the script cannot see (`references/risk-rules.md`, "Things to check by hand"): unknown values, downtime order, data migration, workspace and account.

### 4. Deliver
Fill `templates/plan-risk-summary.md`: verdict, HIGH items with fix or named acceptance, MEDIUM items with owner, expected vs unexpected changes, apply steps with a rollback. Give code fixes as small HCL snippets (moved blocks, lifecycle, scoped policies).

## Example result
`examples/shop-prod-plan.json` (Terraform 1.9.8): 2 create, 4 update, 2 replace, 2 destroy; 6 HIGH, 5 MEDIUM, 2 LOW, 2 INFO, verdict DO NOT APPLY. HIGH: the orders RDS instance is replaced (destroy first) because `storage_encrypted` changed, with deletion protection off and no final snapshot; the legacy logs bucket is deleted with force_destroy; SSH opened to 0.0.0.0/0 on the bastion; the S3 public access block is turned off for the assets bucket; the CI role gets `iam:PassRole` on `*` (and `s3:*`); AdministratorAccess is attached to the ops role. MEDIUM also includes `aws_instance.web[1]` destroyed by a count index shift. Fix: snapshot and migrate the database, `moved` or a `removed` block for the bucket, SSM instead of public SSH, scoped policies, for_each for web instances.

## Rules

- Never print or quote passwords, keys or tokens from a plan, even if they are visible in the JSON.
- A HIGH finding blocks the apply until it is fixed or a named owner accepts it in writing.
- Prefer `moved`, `removed` and `import` blocks over destroy-and-recreate for refactors.
- Data stores need a backup before any destroy or replace, and `prevent_destroy` afterwards.
- Do not approve plans you cannot see in full (truncated output, `-target` runs, errored plans); ask for the complete plan.
- Policy-as-code (OPA/Conftest, Sentinel, Checkov, tfsec) is a good follow-up, not a replacement for reading the plan.
FILE:references/reading-plan-output.md
# Reading Terraform plan output

## Get a machine-readable plan
```
terraform plan -out plan.out            # add -var-file / -target exactly as the real apply will
terraform show -json plan.out > plan.json
terraform show -no-color plan.out > plan.txt   # human-readable copy of the same saved plan
```
Review the SAME saved plan you will apply (`terraform apply plan.out`), not a fresh plan: state or remote resources can change in between. OpenTofu uses the same commands (`tofu show -json`) and format.

## Text symbols
| Symbol | Header line | Meaning |
|---|---|---|
| `+` | `will be created` | new resource |
| `~` | `will be updated in-place` | attributes change, same resource ID |
| `-` | `will be destroyed` | resource deleted |
| `-/+` | `must be replaced` | destroy first, then create (default) - downtime gap |
| `+/-` | `must be replaced` | create first, then destroy (`create_before_destroy`) |
| `<=` | `will be read during apply` | data source read; check why it is unknown at plan time |
| `# forces replacement` | after an attribute | this attribute change causes the replacement |
| `(known after apply)` | value | computed; you cannot review it now |
| `(sensitive value)` | value | hidden in text output, but present in plan JSON |

Reason lines under a header: `# (because aws_x.y is not in configuration)` (removed from code or moved without a `moved` block), `# (because index [1] is out of range for count)` (count shrank or shifted), `# (because key ["a"] is not in for_each map)`, `# (resource is tainted, so must be replaced)`, `# (replace requested)` (`-replace=` flag).

Summary line: `Plan: 3 to add, 1 to change, 3 to destroy.` A replacement counts once in "add" and once in "destroy", so compare destroy count with the number of `-` and `-/+` headers.

## JSON structure (format_version 1.x)
```
{
  "format_version": "1.2", "terraform_version": "1.9.8",
  "resource_changes": [ {
      "address": "module.db.aws_db_instance.main", "module_address": "module.db",
      "mode": "managed" | "data", "type": "aws_db_instance", "name": "main", "index": 0 | "key",
      "change": {
        "actions": ["create"] | ["update"] | ["delete"] | ["delete","create"] | ["create","delete"] | ["no-op"] | ["read"] | ["forget"],
        "before": {...} | null, "after": {...} | null,
        "after_unknown": {"id": true},              # values known only after apply
        "before_sensitive": {...}, "after_sensitive": {"password": true},
        "replace_paths": [["storage_encrypted"]],   # attributes that force replacement
        "importing": {"id": "..."}                  # import blocks (1.5+)
      },
      "action_reason": "replace_because_cannot_update" | "replace_because_tainted" | "replace_by_request" |
                       "delete_because_no_resource_config" | "delete_because_count_index" | "delete_because_each_key" | ...,
      "previous_address": "aws_db_instance.old"     # moved blocks
  } ],
  "resource_drift": [ ... changes made outside Terraform ... ],
  "output_changes": {...}, "prior_state": {...}, "configuration": {...},
  "applyable": true, "complete": true, "errored": false
}
```
Useful one-liners:
```
jq -r '.resource_changes[] | select(.change.actions != ["no-op"]) | "\(.change.actions | join(",")) \(.address)"' plan.json
jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' plan.json
jq -r '.resource_changes[] | select(.change.replace_paths) | "\(.address): \(.change.replace_paths)"' plan.json
```

## Secrets in plans
`before`/`after` contain real values, including passwords and private keys, even when `*_sensitive` marks them. Treat plan.out and plan.json like secrets: do not attach them to tickets, chats or public CI logs; keep them as short-lived, access-controlled CI artifacts. Share the risk summary instead.

## What a plan cannot tell you
- Values computed at apply time (`known after apply`), including many IAM policy documents built from other resources.
- Side effects outside Terraform: DNS caches, clients pinned to an IP, data inside a database being replaced.
- Whether `lifecycle { prevent_destroy = true }` is set (it is in `configuration`, and a plan that would violate it fails instead).
- Provider-specific behaviour such as RDS applying some changes only in the next maintenance window (`apply_immediately`).
FILE:references/risk-rules.md
# Risk rules used by plan_risk.py

| Code | Severity | Trigger | Why it matters | Typical fix |
|---|---|---|---|---|
| DATA_LOSS | HIGH | destroy or replace of a data-holding type: databases (RDS, Cloud SQL, Azure SQL/PostgreSQL/MySQL, Cosmos DB, DynamoDB), buckets, disks/volumes, EFS/FSx, caches, queues/streams, KMS keys, secrets, DNS zones, log groups, ECR, Kubernetes PVCs/namespaces | a new resource starts empty; KMS key deletion makes encrypted data unreadable | snapshot/backup first, `moved` block for refactors, `prevent_destroy` on data stores, migrate data before replacing |
| PROTECTION_OFF | HIGH (update) / MEDIUM (new or replaced) | `deletion_protection`, `deletion_protection_enabled`, `enable_deletion_protection`, `termination_protection`, `disable_api_termination` false | the next mistake can delete production | keep true; disable only in a separate change right before a planned delete |
| PUBLIC_INGRESS | HIGH | new 0.0.0.0/0 or ::/0 ingress on all ports or admin/data ports (22, 3389, 3306, 5432, 1433, 6379, 27017, 9200, 6443 ...) | direct attack surface, brute force, data exposure | known CIDRs, SSM Session Manager, VPN, bastion, private endpoints |
| PUBLIC_INGRESS | MEDIUM / INFO | other ports / 80 and 443 | | expose only load balancers |
| PUBLIC_DATABASE | HIGH | `publicly_accessible = true` | database endpoint reachable from the internet | private subnets, proxy, VPN |
| S3_PUBLIC_ACCESS | HIGH | any of the four public access block settings set to false | bucket policies or ACLs can make data public | keep all four true; CloudFront + Origin Access Control |
| PUBLIC_ACL | HIGH | `public-read`, `public-read-write`, `authenticated-read` | everyone can list or read (or write) | BucketOwnerEnforced, private |
| PUBLIC_POLICY | HIGH | resource policy Allow with Principal `*` and no Condition | anyone on the internet (or any AWS account) gets access | named principals; `aws:SourceArn`, `aws:PrincipalOrgID`, `aws:SourceVpce` |
| PUBLIC_IAM | HIGH | GCP binding to `allUsers` / `allAuthenticatedUsers` | public access | specific members |
| TRUST_ANYONE | HIGH | role trust policy with Principal `*` and no Condition | any AWS account can assume the role | exact ARNs + `sts:ExternalId` |
| IAM_ADMIN | HIGH | `Action * / Resource *`, AdministratorAccess, PowerUserAccess, IAMFullAccess, GCP owner/editor, Azure Owner/User Access Administrator (Contributor = MEDIUM) | full control of the account or project | least privilege; break-glass admin with MFA |
| IAM_ESCALATION | HIGH | `iam:PassRole`, `iam:Attach*/Put*Policy`, `iam:CreatePolicyVersion`, `iam:CreateAccessKey`, `sts:AssumeRole`, `kms:*` on `*`; `iam:*`, `sts:*` anywhere | the holder can grant itself more rights | scope to specific ARNs; `iam:PassedToService` condition |
| IAM_WILDCARD | HIGH on `*` / MEDIUM | `service:*` actions, `*FullAccess` managed policies | far more than needed, includes delete | list actions actually used (Access Analyzer, CloudTrail) |
| IAM_NOT_ACTION | MEDIUM | Allow with NotAction | grants everything else, including future actions | explicit Action list |
| IAM_WIDENED | LOW | update adds actions or resources to a policy | permission creep | confirm with the owner |
| TRUST_WIDENED | MEDIUM | new principals in a trust policy | new cross-account access | confirm each principal |
| NO_FINAL_SNAPSHOT | MEDIUM | `skip_final_snapshot = true` on RDS/Aurora/DocumentDB/Neptune/Redshift | deleting leaves nothing to restore | `false` + `final_snapshot_identifier` |
| FORCE_DESTROY | MEDIUM | `force_destroy = true` newly set | delete wipes all objects or records | keep false on real data |
| ENCRYPTION_OFF | MEDIUM | `storage_encrypted`/`encrypted` false | compliance; enabling later usually forces replacement | enable now |
| BACKUPS_OFF | MEDIUM | `backup_retention_period = 0` | no point-in-time recovery | 7 to 35 days |
| SECRET_REGENERATED | MEDIUM | replace of `random_password`, `random_string`, `tls_private_key`, `aws_iam_access_key` | consumers break | plan the rotation |
| COUNT_INDEX_SHIFT | MEDIUM | `delete_because_count_index` | removing a middle item destroys and recreates the following ones | `for_each` with stable keys, `moved` blocks |
| DESTROY | MEDIUM (compute, LB, IP, cluster, count/each removal) / LOW | non-stateful destroy | downtime, lost IPs | confirm intent; `moved` for refactors |
| REPLACE | MEDIUM (destroy-first compute/LB) / LOW | non-stateful replace | new IDs, IPs, DNS names; downtime gap | `create_before_destroy`, check dependents |
| PUBLIC_FUNCTION_URL | MEDIUM | Lambda URL with auth NONE | public endpoint | AWS_IAM auth |
| PLAN_ERRORED / PLAN_INCOMPLETE | HIGH / MEDIUM | `errored` / `complete: false` | partial picture | re-plan |
| DRIFT, REMOVED_FROM_CONFIG, MOVED, IMPORT, FORGET, POLICY_UNKNOWN, TEXT_MODE | INFO | context | | |

## Verdict
- Any HIGH: do not apply until each HIGH is fixed or explicitly accepted by the owner (name and reason in the review).
- Only MEDIUM: apply after the owner of each affected resource agrees.
- LOW/INFO only: normal review.

## Things to check by hand
- **Blast radius:** how many resources change, and are they all expected from the code diff? A one-line change that replaces 40 resources is a red flag (provider upgrade, changed `name`, `count` to `for_each`).
- **Order and downtime:** destroy-first replacements of anything serving traffic; DNS TTLs; target group swaps.
- **Data migration:** a replaced database needs a restore from snapshot or a data migration plan; Terraform will not copy data.
- **Unknown values:** policies or security group rules that are `known after apply` - review the module code instead.
- **Provider and module upgrades:** read the changelog for forced replacements and default changes.
- **Environment:** confirm the workspace/backend (`terraform workspace show`) and account (`aws sts get-caller-identity`) before applying.
- **Policy as code:** OPA/Conftest, Sentinel, Checkov or tfsec rules can enforce the HIGH checks in CI.
FILE:templates/plan-risk-summary.md
# Terraform plan risk summary: <stack / workspace>

**Change:** <PR #123 "Encrypt orders DB, open bastion for vendor">   **Workspace/account:** <prod / 123456789012 eu-west-1>
**Plan:** <terraform 1.9.8, plan.out from CI run 4567>   **Counts:** <2 create, 4 update, 2 replace, 2 destroy>
**Verdict:** <DO NOT APPLY as is / Apply after review / Low risk>

## Must fix or explicitly accept (HIGH)
| # | Resource | Risk | Why | Fix or acceptance (who) |
|---|---|---|---|---|
| 1 | <`aws_db_instance.orders`> | <DATA_LOSS: replaced because storage_encrypted changed> | <new empty DB; no final snapshot> | <snapshot, restore into encrypted copy, switch with a moved/import plan> |

## Needs owner review (MEDIUM)
| # | Resource | Risk | Owner OK? |
|---|---|---|---|
| 1 | <`aws_instance.web[1]`> | <COUNT_INDEX_SHIFT> | <yes/no> |

## Low / info
- <IAM_WIDENED on ci_deploy: s3:* - replaced by explicit actions in the fix>
- <Drift on aws_lb.public tags: set in console by ...>

## Expected vs unexpected changes
- Expected from the code diff: <...>
- Not explained by the code diff: <... investigate before apply>

## Apply plan
1. <Take snapshot `orders-pre-encrypt-2026-10-27`>
2. <Apply in a maintenance window: <time, zone>>
3. <Verify: app health checks, `aws rds describe-db-instances`, bastion only from VPN>
4. Rollback: <restore snapshot / revert PR and re-apply>

## Follow-ups
- <add prevent_destroy to data stores>
- <policy-as-code rule for 0.0.0.0/0 on port 22>
FILE:examples/shop-prod-plan.json
{
  "format_version": "1.2",
  "terraform_version": "1.9.8",
  "planned_values": {"root_module": {}},
  "resource_drift": [
    {
      "address": "aws_lb.public",
      "mode": "managed",
      "type": "aws_lb",
      "name": "public",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"name": "shop-public", "tags": {"env": "prod"}},
        "after": {"name": "shop-public", "tags": {"env": "prod", "owner": "set-in-console"}},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    }
  ],
  "resource_changes": [
    {
      "address": "aws_cloudwatch_log_group.app",
      "mode": "managed",
      "type": "aws_cloudwatch_log_group",
      "name": "app",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["create"],
        "before": null,
        "after": {"name": "/shop/app", "retention_in_days": 30, "tags": {"env": "prod"}},
        "after_unknown": {"arn": true, "id": true},
        "before_sensitive": false,
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_db_instance.orders",
      "mode": "managed",
      "type": "aws_db_instance",
      "name": "orders",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["delete", "create"],
        "before": {"identifier": "shop-orders", "engine": "postgres", "engine_version": "16.4", "instance_class": "db.t4g.medium", "allocated_storage": 100, "storage_encrypted": false, "deletion_protection": false, "skip_final_snapshot": true, "backup_retention_period": 7, "publicly_accessible": false, "username": "orders_admin", "password": null, "tags": {"env": "prod"}},
        "after": {"identifier": "shop-orders", "engine": "postgres", "engine_version": "16.4", "instance_class": "db.t4g.medium", "allocated_storage": 100, "storage_encrypted": true, "deletion_protection": false, "skip_final_snapshot": true, "backup_retention_period": 7, "publicly_accessible": false, "username": "orders_admin", "password": null, "tags": {"env": "prod"}, "kms_key_id": null},
        "after_unknown": {"id": true, "arn": true, "endpoint": true, "address": true},
        "before_sensitive": {"password": true},
        "after_sensitive": {"password": true},
        "replace_paths": [["storage_encrypted"]]
      },
      "action_reason": "replace_because_cannot_update"
    },
    {
      "address": "aws_iam_role_policy.ci_deploy",
      "mode": "managed",
      "type": "aws_iam_role_policy",
      "name": "ci_deploy",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"id": "shop-ci:deploy", "name": "deploy", "role": "shop-ci", "policy": "{\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"Deploy\", \"Effect\": \"Allow\", \"Action\": [\"s3:PutObject\", \"s3:GetObject\"], \"Resource\": \"arn:aws:s3:::shop-assets-prod/*\"}]}"},
        "after": {"id": "shop-ci:deploy", "name": "deploy", "role": "shop-ci", "policy": "{\"Version\": \"2012-10-17\", \"Statement\": [{\"Sid\": \"Deploy\", \"Effect\": \"Allow\", \"Action\": \"s3:*\", \"Resource\": [\"arn:aws:s3:::shop-assets-prod\", \"arn:aws:s3:::shop-assets-prod/*\"]}, {\"Sid\": \"PassAnyRole\", \"Effect\": \"Allow\", \"Action\": \"iam:PassRole\", \"Resource\": \"*\"}]}"},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_iam_role_policy_attachment.ops_admin",
      "mode": "managed",
      "type": "aws_iam_role_policy_attachment",
      "name": "ops_admin",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["create"],
        "before": null,
        "after": {"role": "shop-ops", "policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess"},
        "after_unknown": {"id": true},
        "before_sensitive": false,
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_instance.web[1]",
      "mode": "managed",
      "type": "aws_instance",
      "name": "web",
      "index": 1,
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["delete"],
        "before": {"id": "i-0b1c2d3e4f5a6b7c8", "ami": "ami-0abc1234def567890", "instance_type": "t3.medium", "associate_public_ip_address": false, "tags": {"Name": "shop-web-1"}},
        "after": null,
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": false
      },
      "action_reason": "delete_because_count_index"
    },
    {
      "address": "aws_lb.public",
      "mode": "managed",
      "type": "aws_lb",
      "name": "public",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"name": "shop-public", "internal": false, "enable_deletion_protection": true, "tags": {"env": "prod", "owner": "set-in-console"}},
        "after": {"name": "shop-public", "internal": false, "enable_deletion_protection": true, "tags": {"env": "prod", "team": "shop"}},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_lb_target_group.web",
      "mode": "managed",
      "type": "aws_lb_target_group",
      "name": "web",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["create", "delete"],
        "before": {"name_prefix": "web-", "port": 8080, "protocol": "HTTP", "vpc_id": "vpc-0a1b2c3d"},
        "after": {"name_prefix": "web-", "port": 8081, "protocol": "HTTP", "vpc_id": "vpc-0a1b2c3d"},
        "after_unknown": {"id": true, "arn": true, "name": true},
        "before_sensitive": {},
        "after_sensitive": {},
        "replace_paths": [["port"]]
      },
      "action_reason": "replace_because_cannot_update"
    },
    {
      "address": "aws_s3_bucket.legacy_logs",
      "mode": "managed",
      "type": "aws_s3_bucket",
      "name": "legacy_logs",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["delete"],
        "before": {"bucket": "shop-legacy-logs-prod", "force_destroy": true, "tags": {"env": "prod"}},
        "after": null,
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": false
      },
      "action_reason": "delete_because_no_resource_config"
    },
    {
      "address": "aws_s3_bucket_public_access_block.assets",
      "mode": "managed",
      "type": "aws_s3_bucket_public_access_block",
      "name": "assets",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"bucket": "shop-assets-prod", "block_public_acls": true, "block_public_policy": true, "ignore_public_acls": true, "restrict_public_buckets": true},
        "after": {"bucket": "shop-assets-prod", "block_public_acls": true, "block_public_policy": false, "ignore_public_acls": true, "restrict_public_buckets": false},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    },
    {
      "address": "aws_security_group.bastion",
      "mode": "managed",
      "type": "aws_security_group",
      "name": "bastion",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["update"],
        "before": {"name": "shop-bastion", "ingress": [{"cidr_blocks": ["10.20.0.0/16"], "description": "SSH from VPN", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}], "egress": []},
        "after": {"name": "shop-bastion", "ingress": [{"cidr_blocks": ["10.20.0.0/16"], "description": "SSH from VPN", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}, {"cidr_blocks": ["0.0.0.0/0"], "description": "temp: SSH for vendor", "from_port": 22, "to_port": 22, "protocol": "tcp", "ipv6_cidr_blocks": [], "prefix_list_ids": [], "security_groups": [], "self": false}], "egress": []},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    },
    {
      "address": "module.network.aws_vpc.main",
      "module_address": "module.network",
      "mode": "managed",
      "type": "aws_vpc",
      "name": "main",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["no-op"],
        "before": {"cidr_block": "10.20.0.0/16"},
        "after": {"cidr_block": "10.20.0.0/16"},
        "after_unknown": {},
        "before_sensitive": {},
        "after_sensitive": {}
      }
    }
  ],
  "prior_state": {"format_version": "1.0", "terraform_version": "1.9.8"},
  "configuration": {"provider_config": {"aws": {"name": "aws", "full_name": "registry.terraform.io/hashicorp/aws"}}},
  "timestamp": "2026-10-27T07:10:00Z",
  "applyable": true,
  "complete": true,
  "errored": false
}
FILE:scripts/plan_risk.py
#!/usr/bin/env python3
"""Terraform plan risk reviewer (Python 3 standard library only).

Reads `terraform show -json plan.out` output (or, with less detail, the text of
`terraform plan -no-color`) and flags risky changes: destroys and replacements of
stateful resources (data loss), removed deletion protection, skipped final
snapshots, force_destroy, disabled encryption, new public network exposure
(0.0.0.0/0 or ::/0 ingress, public databases, S3 public access blocks, public ACLs
and policies, allUsers bindings), IAM widening (wildcard actions, admin policies,
iam:PassRole on *, trust policies open to anyone) and count index shifts. Prints a
risk summary. Never prints attribute values that Terraform marks as sensitive.

Usage:
  terraform plan -out plan.out && terraform show -json plan.out > plan.json
  python3 plan_risk.py plan.json [--json]
  terraform plan -no-color > plan.txt && python3 plan_risk.py plan.txt   (text mode)
  python3 plan_risk.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = unreadable input.
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
STATEFUL = re.compile(r"^(aws_(db_instance|rds_cluster|dynamodb_table|s3_bucket|ebs_volume|efs_file_system|"
                      r"elasticache_(cluster|replication_group)|redshift_cluster|docdb_cluster|neptune_cluster|"
                      r"opensearch_domain|elasticsearch_domain|kinesis_stream|sqs_queue|kms_key|secretsmanager_secret|"
                      r"backup_vault|ecr_repository|cloudwatch_log_group|msk_cluster|fsx_\w+|route53_zone|"
                      r"cognito_user_pool|timestream_table)|"
                      r"google_(sql_database_instance|sql_database|storage_bucket|bigquery_(dataset|table)|compute_disk|"
                      r"spanner_(instance|database)|kms_crypto_key|redis_instance|filestore_instance|firestore_database|"
                      r"dns_managed_zone)|"
                      r"azurerm_(storage_account|storage_container|mssql_(database|server)|postgresql_flexible_server|"
                      r"mysql_flexible_server|cosmosdb_account|key_vault|managed_disk|redis_cache|dns_zone)|"
                      r"kubernetes_(persistent_volume_claim|persistent_volume|namespace)(_v1)?)$")
SECRET_GEN = re.compile(r"^(random_password|random_string|tls_private_key|aws_iam_access_key)$")
DOWNTIME = re.compile(r"^(aws_(instance|lb|alb|eip|nat_gateway|cloudfront_distribution|api_gateway_rest_api|"
                      r"eks_cluster|ecs_service|acm_certificate)|google_(compute_instance|container_cluster)|"
                      r"azurerm_(linux|windows)_virtual_machine|azurerm_kubernetes_cluster)$")
SENSITIVE_PORTS = {22: "SSH", 23: "Telnet", 3389: "RDP", 3306: "MySQL", 5432: "PostgreSQL", 1433: "SQL Server",
                   1521: "Oracle", 6379: "Redis", 27017: "MongoDB", 9200: "Elasticsearch", 5601: "Kibana",
                   2375: "Docker API", 2379: "etcd", 11211: "Memcached", 5900: "VNC", 9092: "Kafka",
                   6443: "Kubernetes API", 10250: "Kubelet"}
WEB_PORTS = {80, 443}
PUBLIC_CIDRS = {"0.0.0.0/0", "::/0", "*", "internet", "any"}
ADMIN_POLICIES = re.compile(r"(AdministratorAccess|PowerUserAccess|IAMFullAccess)$")
ESCALATION_ACTIONS = {"iam:*", "iam:passrole", "iam:createpolicyversion", "iam:attachrolepolicy", "iam:putrolepolicy",
                      "iam:createaccesskey", "iam:updateassumerolepolicy", "sts:assumerole", "sts:*", "kms:*", "*"}
POLICY_ATTRS = ("policy", "assume_role_policy", "inline_policy")
PROTECTION_ATTRS = ("deletion_protection", "deletion_protection_enabled", "enable_deletion_protection",
                    "termination_protection", "disable_api_termination")


def finding(sev, code, addr, problem, fix):
    return {"severity": sev, "code": code, "resource": addr, "problem": problem, "fix": fix}


def kind_of(actions):
    a = list(actions or [])
    if a == ["delete", "create"]:
        return "replace (destroy first)"
    if a == ["create", "delete"]:
        return "replace (create first)"
    return {"create": "create", "update": "update", "delete": "destroy", "no-op": "no-op", "read": "read",
            "forget": "forget"}.get(a[0] if len(a) == 1 else ",".join(a), ",".join(a))


def sensitive(mask, key):
    return mask is True or (isinstance(mask, dict) and mask.get(key) not in (None, False))


def get(d, key, default=None):
    return d.get(key, default) if isinstance(d, dict) else default


def ports_of(lo, hi, proto):
    proto = str(proto).lower()
    if proto in ("-1", "all", "*"):
        return 0, 65535, "all"
    try:
        lo, hi = int(lo if lo not in (None, "") else 0), int(hi if hi not in (None, "") else 65535)
    except (TypeError, ValueError):
        return 0, 65535, proto
    if proto in ("icmp", "icmpv6", "1", "58"):
        return None
    if lo in (-1,) or (lo == 0 and hi in (0, 65535)):
        return 0, 65535, proto
    return lo, hi, proto


def open_rules(rtype, values):
    """Yield (cidr, lo, hi, proto) for internet-open ingress rules in a resource's values."""
    v = values or {}
    out = set()
    def add(cidrs, lo, hi, proto):
        p = ports_of(lo, hi, proto)
        if p is None:
            return
        for c in cidrs or []:
            if str(c).lower() in PUBLIC_CIDRS:
                out.add((str(c), p[0], p[1], p[2]))
    if rtype == "aws_security_group":
        for r in v.get("ingress") or []:
            add((r.get("cidr_blocks") or []) + (r.get("ipv6_cidr_blocks") or []), r.get("from_port"), r.get("to_port"), r.get("protocol"))
    elif rtype == "aws_security_group_rule" and v.get("type") == "ingress":
        add((v.get("cidr_blocks") or []) + (v.get("ipv6_cidr_blocks") or []), v.get("from_port"), v.get("to_port"), v.get("protocol"))
    elif rtype == "aws_vpc_security_group_ingress_rule":
        add([x for x in (v.get("cidr_ipv4"), v.get("cidr_ipv6")) if x], v.get("from_port"), v.get("to_port"), v.get("ip_protocol"))
    elif rtype == "google_compute_firewall" and str(v.get("direction", "INGRESS")).upper() == "INGRESS":
        for al in v.get("allow") or []:
            ports = al.get("ports") or ["0-65535"]
            for pr in ports:
                lo, _, hi = str(pr).partition("-")
                add(v.get("source_ranges"), lo, hi or lo, al.get("protocol", "all"))
    elif rtype == "azurerm_network_security_rule" and str(v.get("direction")).lower() == "inbound" and str(v.get("access")).lower() == "allow":
        src = [v.get("source_address_prefix")] + (v.get("source_address_prefixes") or [])
        rng = v.get("destination_port_range") or "*"
        for pr in [rng] + (v.get("destination_port_ranges") or []):
            if pr == "*":
                add(src, 0, 65535, v.get("protocol", "*"))
            else:
                lo, _, hi = str(pr).partition("-")
                add(src, lo, hi or lo, v.get("protocol", "*"))
    return out


def port_label(lo, hi, proto):
    if proto == "all" or (lo == 0 and hi == 65535):
        return "all ports"
    names = [f"{p} ({n})" for p, n in SENSITIVE_PORTS.items() if lo <= p <= hi]
    rng = str(lo) if lo == hi else f"{lo}-{hi}"
    return f"port {rng}" + (f" incl. {', '.join(names[:3])}" if names and lo != hi else (f" ({SENSITIVE_PORTS[lo]})" if lo == hi and lo in SENSITIVE_PORTS else ""))


def parse_policy(doc):
    if isinstance(doc, str):
        try:
            doc = json.loads(doc)
        except ValueError:
            return None
    if not isinstance(doc, dict):
        return None
    st = doc.get("Statement") or []
    return [st] if isinstance(st, dict) else st


def as_list(x):
    return x if isinstance(x, list) else ([] if x is None else [x])


def allow_actions(stmts):
    acts = set()
    for s in stmts or []:
        if s.get("Effect", "Allow") != "Allow":
            continue
        for a in as_list(s.get("Action")):
            for r in as_list(s.get("Resource")) or ["(none)"]:
                acts.add((str(a), str(r)))
    return acts


def check_policy(addr, attr, before, after, unknown):
    out = []
    if unknown:
        return [finding("INFO", "POLICY_UNKNOWN", addr, f"{attr} is only known after apply", "Review the rendered policy after apply or render it with a data source in a separate plan.")]
    b, a = parse_policy(before), parse_policy(after)
    if a is None:
        return out
    if attr == "assume_role_policy":
        for s in a:
            if s.get("Effect", "Allow") != "Allow":
                continue
            pr = s.get("Principal")
            vals = [pr] if isinstance(pr, str) else [v for x in (pr or {}).values() for v in as_list(x)]
            if "*" in vals and not s.get("Condition"):
                out.append(finding("HIGH", "TRUST_ANYONE", addr, "trust policy lets any AWS principal assume this role (Principal * without Condition)",
                                   "Name the exact account or role ARNs, and add sts:ExternalId or aws:PrincipalOrgID conditions."))
        bp = {v for s in (b or []) for x in (get(s, "Principal") or {}).values() for v in as_list(x)} if b and all(isinstance(get(s, "Principal"), dict) for s in b) else set()
        ap = {v for s in a if isinstance(s.get("Principal"), dict) for x in s["Principal"].values() for v in as_list(x)}
        new = sorted(ap - bp - {"*"})
        if b is not None and new:
            out.append(finding("MEDIUM", "TRUST_WIDENED", addr, "new principals can assume this role: " + ", ".join(new), "Confirm each new principal is expected (cross-account access?)."))
        return out
    for s in a:
        if s.get("Effect", "Allow") != "Allow":
            continue
        orig = [str(x) for x in as_list(s.get("Action"))]
        acts = [x.lower() for x in orig]
        res = [str(x) for x in as_list(s.get("Resource"))]
        pr = s.get("Principal")
        if pr is not None:
            vals = [pr] if isinstance(pr, str) else [v for x in pr.values() for v in as_list(x)]
            if "*" in vals and not s.get("Condition"):
                out.append(finding("HIGH", "PUBLIC_POLICY", addr, f"resource policy allows Principal * ({', '.join(acts)[:80]}) without a Condition",
                                   "Restrict Principal to known ARNs or add aws:SourceArn / aws:PrincipalOrgID / aws:SourceVpce conditions; use CloudFront OAC for public sites."))
        if s.get("NotAction"):
            out.append(finding("MEDIUM", "IAM_NOT_ACTION", addr, "Allow with NotAction grants everything except the listed actions", "List the allowed actions explicitly."))
        if "*" in acts and "*" in res:
            out.append(finding("HIGH", "IAM_ADMIN", addr, "Allow Action * on Resource * (full admin)", "Grant only the actions and resources this principal needs."))
            continue
        esc = sorted({o for o, x in zip(orig, acts) if x in ESCALATION_ACTIONS and x != "*" and ("*" in res or x in ("iam:*", "sts:*"))})
        if esc:
            out.append(finding("HIGH", "IAM_ESCALATION", addr, f"{', '.join(esc)} on {', '.join(res) or '*'} lets the holder gain more privileges",
                               "Scope iam:PassRole and sts:AssumeRole to specific role ARNs (and iam:PassedToService); avoid iam:* and kms:* wildcards."))
        wild = sorted({o for o, x in zip(orig, acts) if x.endswith(":*") and x not in ESCALATION_ACTIONS})
        if wild:
            sev = "HIGH" if "*" in res else "MEDIUM"
            out.append(finding(sev, "IAM_WILDCARD", addr, f"service wildcard {', '.join(wild)} on {', '.join(res)}",
                               "Replace with the specific actions used (check IAM Access Analyzer or CloudTrail for what is actually called)."))
    if b is not None:
        added = sorted({x[0] + (" on " + x[1] if x[1] not in ("*", "(none)") else " on *") for x in allow_actions(a) - allow_actions(b)})
        if added:
            out.append(finding("LOW", "IAM_WIDENED", addr, "policy grants new actions/resources: " + ", ".join(added[:8]) + (" ..." if len(added) > 8 else ""),
                               "Confirm the new permissions are needed; least privilege."))
    return out


def review_change(rc):
    out = []
    addr, rtype = rc.get("address", "?"), rc.get("type", "")
    ch = rc.get("change") or {}
    actions = ch.get("actions") or []
    kind = kind_of(actions)
    before, after = ch.get("before") or {}, ch.get("after") or {}
    unknown = ch.get("after_unknown") or {}
    reason = rc.get("action_reason", "")
    if rc.get("mode") == "data" or kind in ("no-op", "read"):
        return kind, out
    why = ""
    if ch.get("replace_paths"):
        why = " (forced by " + ", ".join(".".join(map(str, p)) for p in ch["replace_paths"]) + ")"
    elif reason:
        why = f" ({reason.replace('_', ' ')})"
    stateful = bool(STATEFUL.match(rtype))
    if kind == "forget":
        out.append(finding("INFO", "FORGET", addr, "removed from state but not destroyed (removed block)", "Make sure another configuration or team takes ownership of it."))
    if kind == "destroy" or kind.startswith("replace"):
        if stateful:
            extra = "; force_destroy = true, so everything in it is deleted too" if get(before, "force_destroy") is True else ""
            out.append(finding("HIGH", "DATA_LOSS", addr, f"{rtype} will be {'destroyed' if kind == 'destroy' else 'replaced, ' + kind[9:-1]}{why}; its data does not carry over{extra}",
                               "Back up or snapshot first; if this is a rename/refactor add a `moved` block or `terraform state mv`; add lifecycle prevent_destroy for data stores."))
        elif SECRET_GEN.match(rtype) and kind.startswith("replace"):
            out.append(finding("MEDIUM", "SECRET_REGENERATED", addr, f"{rtype} will be regenerated{why}; everything using the old value breaks",
                               "Check what consumes it (database password, keys) and plan the rotation."))
        elif kind == "destroy":
            sev = "MEDIUM" if DOWNTIME.match(rtype) or reason in ("delete_because_count_index", "delete_because_each_key") else "LOW"
            out.append(finding(sev, "DESTROY", addr, f"will be destroyed{why}",
                               "Confirm it is intended; for a rename/refactor use a `moved` block instead of destroy + create."))
        else:
            sev = "MEDIUM" if kind == "replace (destroy first)" and DOWNTIME.match(rtype) else "LOW"
            out.append(finding(sev, "REPLACE", addr, f"{kind}{why}",
                               "Expect a new ID/IP/DNS name; use create_before_destroy where possible and check dependents."))
        if reason in ("delete_because_count_index",):
            out.append(finding("MEDIUM", "COUNT_INDEX_SHIFT", addr, "destroyed because a count index moved (an item was removed from the middle of a list)",
                               "Switch to for_each with stable keys, or add moved blocks for the shifted indexes."))
        if reason == "delete_because_no_resource_config":
            out.append(finding("INFO", "REMOVED_FROM_CONFIG", addr, "resource no longer in configuration", "If it moved to another module or address, add a `moved` block; to keep it but stop managing it, use a `removed` block with destroy = false."))
    if kind in ("create", "update") or kind.startswith("replace"):
        bm, am = ch.get("before_sensitive") or {}, ch.get("after_sensitive") or {}
        for attr in PROTECTION_ATTRS:
            if get(after, attr) is False and (kind != "update" or get(before, attr) is True):
                out.append(finding("HIGH" if kind == "update" else "MEDIUM", "PROTECTION_OFF", addr, f"{attr} {'turned off' if kind == 'update' else 'is false'}",
                                   f"Keep {attr} = true for production resources; turn it off only in a separate, reviewed change right before a planned delete."))
        if get(after, "skip_final_snapshot") is True and rtype in ("aws_db_instance", "aws_rds_cluster", "aws_docdb_cluster", "aws_neptune_cluster", "aws_redshift_cluster"):
            if kind != "update" or get(before, "skip_final_snapshot") is not True:
                out.append(finding("MEDIUM", "NO_FINAL_SNAPSHOT", addr, "skip_final_snapshot = true: a delete leaves no snapshot",
                                   "Set skip_final_snapshot = false with a final_snapshot_identifier."))
        if get(after, "force_destroy") is True and (kind != "update" or get(before, "force_destroy") is not True):
            out.append(finding("MEDIUM", "FORCE_DESTROY", addr, "force_destroy = true lets Terraform delete the bucket/zone with everything in it",
                               "Keep force_destroy = false on buckets and zones that hold real data."))
        for attr in ("storage_encrypted", "encrypted", "server_side_encryption"):
            if get(after, attr) is False and (kind != "update" or get(before, attr) is True):
                out.append(finding("MEDIUM", "ENCRYPTION_OFF", addr, f"{attr} = false", "Enable encryption at rest (it usually forces replacement later, so do it now)."))
        if get(after, "backup_retention_period") == 0 and get(before, "backup_retention_period", 1) != 0:
            out.append(finding("MEDIUM", "BACKUPS_OFF", addr, "backup_retention_period = 0 disables automated backups", "Keep at least 7 days for production databases."))
        if get(after, "publicly_accessible") is True and get(before, "publicly_accessible") is not True:
            out.append(finding("HIGH", "PUBLIC_DATABASE", addr, "publicly_accessible = true gives the database a public endpoint",
                               "Keep databases private; connect through a bastion, VPN, SSM or RDS Proxy."))
        if rtype == "aws_s3_bucket_public_access_block":
            off = [k for k in ("block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets")
                   if get(after, k) is False and (kind != "update" or get(before, k) is not False)]
            if off:
                out.append(finding("HIGH", "S3_PUBLIC_ACCESS", addr, "S3 public access block turned off: " + ", ".join(off),
                                   "Keep all four settings true; serve public content through CloudFront with Origin Access Control."))
        if rtype in ("aws_s3_bucket_acl", "aws_s3_object") and get(after, "acl") in ("public-read", "public-read-write", "authenticated-read"):
            out.append(finding("HIGH", "PUBLIC_ACL", addr, f"acl = {after['acl']}", "Use private ACLs (BucketOwnerEnforced) and CloudFront for public files."))
        if rtype.startswith("google_") and "iam" in rtype:
            members = as_list(get(after, "member")) + as_list(get(after, "members"))
            if any(m in ("allUsers", "allAuthenticatedUsers") for m in members):
                out.append(finding("HIGH", "PUBLIC_IAM", addr, "grants a role to allUsers/allAuthenticatedUsers", "Grant to specific users, groups or service accounts."))
            if get(after, "role") in ("roles/owner", "roles/editor", "roles/iam.securityAdmin"):
                out.append(finding("HIGH", "IAM_ADMIN", addr, f"grants {after['role']}", "Use a narrower predefined or custom role."))
        if rtype == "azurerm_role_assignment" and get(after, "role_definition_name") in ("Owner", "User Access Administrator", "Contributor"):
            sev = "MEDIUM" if after["role_definition_name"] == "Contributor" else "HIGH"
            out.append(finding(sev, "IAM_ADMIN", addr, f"assigns {after['role_definition_name']}", "Use the narrowest built-in role at the smallest scope."))
        if rtype.startswith("aws_iam_") and rtype.endswith("policy_attachment"):
            arn = get(after, "policy_arn") or ""
            if ADMIN_POLICIES.search(arn) and get(before, "policy_arn") != arn:
                out.append(finding("HIGH", "IAM_ADMIN", addr, f"attaches {arn.split('/')[-1]}", "Attach a least-privilege policy instead; keep admin roles for break-glass use with MFA."))
            elif arn.endswith("FullAccess") and get(before, "policy_arn") != arn:
                out.append(finding("MEDIUM", "IAM_WILDCARD", addr, f"attaches {arn.split('/')[-1]}", "Prefer a scoped customer-managed policy."))
        if rtype == "aws_lambda_function_url" and get(after, "authorization_type") == "NONE":
            out.append(finding("MEDIUM", "PUBLIC_FUNCTION_URL", addr, "Lambda function URL with authorization_type NONE is public", "Use AWS_IAM auth or validate requests in the function."))
        new_open = open_rules(rtype, after) - (open_rules(rtype, before) if kind == "update" else set())
        for cidr, lo, hi, proto in sorted(new_open, key=lambda x: (x[1], x[2])):
            label = port_label(lo, hi, proto)
            if proto == "all" or (lo == 0 and hi == 65535) or any(lo <= p <= hi for p in SENSITIVE_PORTS):
                sev = "HIGH"
            elif lo in WEB_PORTS and hi == lo:
                sev = "INFO"
            else:
                sev = "MEDIUM"
            out.append(finding(sev, "PUBLIC_INGRESS", addr, f"new ingress from {cidr} on {label}",
                               "Restrict the source to known CIDRs or a load balancer security group; reach admin ports through SSM, VPN or a bastion." if sev != "INFO" else "Expected for a public web endpoint; make sure only the load balancer is exposed."))
        for attr in POLICY_ATTRS:
            if attr in after or get(unknown, attr):
                if sensitive(am, attr):
                    continue
                out += check_policy(addr, attr, get(before, attr), get(after, attr), get(unknown, attr) is True)
    return kind, out


def review_json(plan):
    if not isinstance(plan, dict) or "format_version" not in plan:
        raise ValueError("not a terraform show -json document (no format_version)")
    changes, findings = [], []
    for rc in plan.get("resource_changes") or []:
        kind, f = review_change(rc)
        if kind not in ("no-op", "read") and rc.get("mode") != "data":
            changes.append((kind, rc.get("address", "?")))
        findings += f
        if (rc.get("change") or {}).get("importing"):
            findings.append(finding("INFO", "IMPORT", rc.get("address", "?"), "will be imported", "Check the plan shows no further changes after import."))
        if rc.get("previous_address"):
            findings.append(finding("INFO", "MOVED", rc.get("address", "?"), f"moved from {rc['previous_address']}", "No action; moved blocks avoid destroy + create."))
    drift = [d.get("address", "?") for d in plan.get("resource_drift") or []]
    if drift:
        findings.append(finding("INFO", "DRIFT", ", ".join(drift[:5]) + (" ..." if len(drift) > 5 else ""),
                                f"{len(drift)} resource(s) changed outside Terraform", "Find out who changed them; the plan may revert manual fixes."))
    if plan.get("errored"):
        findings.append(finding("HIGH", "PLAN_ERRORED", "-", "the plan errored", "Fix the errors and re-plan before applying anything."))
    elif plan.get("complete") is False:
        findings.append(finding("MEDIUM", "PLAN_INCOMPLETE", "-", "plan is not complete (deferred or -target changes)", "Run a full plan after this apply."))
    return changes, findings, plan.get("terraform_version", "?")


TEXT_RE = re.compile(r"^\s*# (\S+) (will be created|will be destroyed|will be updated in-place|must be replaced|will be read during apply|will no longer be managed by Terraform)")


def review_text(text):
    text = re.sub(r"\x1b\[[0-9;]*m", "", text)
    lines = text.splitlines()
    changes, findings = [], []
    i = 0
    while i < len(lines):
        m = TEXT_RE.match(lines[i])
        if not m:
            i += 1; continue
        addr, verb = m.group(1), m.group(2)
        rtype = re.sub(r"^(module\.[^.]+\.)*", "", addr).split(".")[0]
        if addr.startswith("data.") or ".data." in addr or "read during apply" in verb:
            i += 1; continue
        block, j = [], i + 1
        while j < len(lines) and not TEXT_RE.match(lines[j]) and not lines[j].startswith("Plan:"):
            block.append(lines[j]); j += 1
        first = next((l.strip() for l in block if l.strip() and not l.strip().startswith("#")), "")
        reason = next((l.strip()[3:-1] for l in block[:3] if l.strip().startswith("# (")), "")
        kind = {"will be created": "create", "will be destroyed": "destroy", "will be updated in-place": "update",
                "will no longer be managed by Terraform": "forget"}.get(verb)
        if verb == "must be replaced":
            kind = "replace (create first)" if first.startswith("+/-") else "replace (destroy first)"
        forced = [re.sub(r"^[~+\-/ ]+", "", l.strip()).split(" ")[0] for l in block if "# forces replacement" in l]
        rc = {"address": addr, "type": rtype, "change": {"actions": {"create": ["create"], "update": ["update"], "destroy": ["delete"], "forget": ["forget"],
              "replace (create first)": ["create", "delete"], "replace (destroy first)": ["delete", "create"]}[kind],
              "replace_paths": [[f] for f in forced]}}
        if "out of range for count" in reason or "count index" in reason:
            rc["action_reason"] = "delete_because_count_index"
        elif "not in for_each map" in reason:
            rc["action_reason"] = "delete_because_each_key"
        elif "not in configuration" in reason:
            rc["action_reason"] = "delete_because_no_resource_config"
        k, f = review_change(rc)
        changes.append((k, addr))
        findings += f
        i = j
    if not changes and "No changes." not in text:
        raise ValueError("no resource changes found; is this terraform plan output?")
    findings.append(finding("INFO", "TEXT_MODE", "-", "text plan: only actions were checked, not attribute values (IAM, ingress, protection flags)",
                            "Run `terraform show -json plan.out > plan.json` and review the JSON for full checks."))
    return changes, findings, "version unknown (text plan)"


def summarize(changes, findings):
    counts = {}
    for k, _ in changes:
        key = "replace" if k.startswith("replace") else k
        counts[key] = counts.get(key, 0) + 1
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["resource"]))
    sev = {s: sum(1 for f in findings if f["severity"] == s) for s in SEV_ORDER}
    if sev["HIGH"]:
        verdict = "DO NOT APPLY as is: resolve or explicitly accept every HIGH finding"
    elif sev["MEDIUM"]:
        verdict = "Apply after review: MEDIUM findings need an owner's OK"
    else:
        verdict = "Low risk"
    return counts, sev, verdict


def render(path, version, changes, findings):
    counts, sev, verdict = summarize(changes, findings)
    out = [f"# Terraform plan risk review: {path}", "",
           f"Terraform {version}. Changes: " + ", ".join(f"{counts.get(k, 0)} {k}" for k in ("create", "update", "replace", "destroy")) +
           (f", {counts['forget']} forget" if counts.get("forget") else ""),
           f"Findings: {sev['HIGH']} HIGH, {sev['MEDIUM']} MEDIUM, {sev['LOW']} LOW, {sev['INFO']} INFO",
           f"Verdict: {verdict}", "", "## Findings", ""]
    if findings:
        out += ["| Severity | Code | Resource | Problem | What to do |", "|---|---|---|---|---|"]
        for f in findings:
            out.append(f"| {f['severity']} | {f['code']} | `{f['resource']}` | {f['problem']} | {f['fix']} |")
    else:
        out.append("No findings.")
    out += ["", "## All changes", "", "| Action | Resource |", "|---|---|"]
    order = {"destroy": 0, "replace (destroy first)": 1, "replace (create first)": 2, "update": 3, "create": 4, "forget": 5}
    for k, a in sorted(changes, key=lambda x: (order.get(x[0], 9), x[1])):
        out.append(f"| {k} | `{a}` |")
    out += ["", "Plan JSON can contain secrets in plain text: do not paste it into tickets or chats; share this summary instead."]
    return "\n".join(out)


def self_test():
    cases = []
    def t(name, cond):
        cases.append((name, bool(cond)))
    def codes(rc):
        return {f["code"]: f["severity"] for f in review_change(rc)[1]}
    def rc(rtype, actions, before=None, after=None, **kw):
        d = {"address": f"{rtype}.x", "mode": "managed", "type": rtype, "change": {"actions": actions, "before": before, "after": after, **kw.pop("change", {})}}
        d.update(kw)
        return d
    t("kind names", kind_of(["delete", "create"]) == "replace (destroy first)" and kind_of(["create", "delete"]) == "replace (create first)" and kind_of(["delete"]) == "destroy")
    t("db destroy = HIGH DATA_LOSS", codes(rc("aws_db_instance", ["delete"], {"id": "a"}, None)).get("DATA_LOSS") == "HIGH")
    t("bucket replace = HIGH DATA_LOSS", codes(rc("aws_s3_bucket", ["create", "delete"], {}, {})).get("DATA_LOSS") == "HIGH")
    t("stateless destroy LOW", codes(rc("aws_iam_role", ["delete"], {}, None)).get("DESTROY") == "LOW")
    t("instance destroy-first replace MEDIUM", codes(rc("aws_instance", ["delete", "create"], {}, {})).get("REPLACE") == "MEDIUM")
    t("create-first replace LOW", codes(rc("aws_lb_target_group", ["create", "delete"], {}, {})).get("REPLACE") == "LOW")
    t("replace_paths shown", "forced by engine" in review_change(rc("aws_instance", ["delete", "create"], {}, {}, change={"replace_paths": [["engine"]]}))[1][0]["problem"])
    t("random_password regenerated", codes(rc("random_password", ["delete", "create"], {}, {})).get("SECRET_REGENERATED") == "MEDIUM")
    t("count index shift", "COUNT_INDEX_SHIFT" in codes(rc("aws_instance", ["delete"], {}, None, action_reason="delete_because_count_index")))
    t("no-op ignored", review_change(rc("aws_vpc", ["no-op"], {}, {}))[1] == [])
    t("deletion_protection turned off HIGH", codes(rc("aws_db_instance", ["update"], {"deletion_protection": True}, {"deletion_protection": False})).get("PROTECTION_OFF") == "HIGH")
    t("skip_final_snapshot MEDIUM", "NO_FINAL_SNAPSHOT" in codes(rc("aws_db_instance", ["create"], None, {"skip_final_snapshot": True})))
    t("force_destroy MEDIUM", "FORCE_DESTROY" in codes(rc("aws_s3_bucket", ["update"], {"force_destroy": False}, {"force_destroy": True})))
    t("unchanged force_destroy not re-flagged", "FORCE_DESTROY" not in codes(rc("aws_s3_bucket", ["update"], {"force_destroy": True}, {"force_destroy": True})))
    t("encryption off", "ENCRYPTION_OFF" in codes(rc("aws_ebs_volume", ["create"], None, {"encrypted": False})))
    t("publicly_accessible HIGH", codes(rc("aws_db_instance", ["update"], {"publicly_accessible": False}, {"publicly_accessible": True})).get("PUBLIC_DATABASE") == "HIGH")
    sg_b = {"ingress": [{"cidr_blocks": ["10.0.0.0/8"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]}
    sg_a = {"ingress": sg_b["ingress"] + [{"cidr_blocks": ["0.0.0.0/0"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]}
    t("new SSH from 0.0.0.0/0 HIGH", codes(rc("aws_security_group", ["update"], sg_b, sg_a)).get("PUBLIC_INGRESS") == "HIGH")
    t("existing open rule not re-flagged", "PUBLIC_INGRESS" not in codes(rc("aws_security_group", ["update"], sg_a, sg_a)))
    t("443 from internet INFO", codes(rc("aws_vpc_security_group_ingress_rule", ["create"], None, {"cidr_ipv4": "0.0.0.0/0", "from_port": 443, "to_port": 443, "ip_protocol": "tcp"})).get("PUBLIC_INGRESS") == "INFO")
    t("all protocols HIGH", codes(rc("aws_security_group_rule", ["create"], None, {"type": "ingress", "cidr_blocks": ["0.0.0.0/0"], "from_port": 0, "to_port": 0, "protocol": "-1"})).get("PUBLIC_INGRESS") == "HIGH")
    t("ipv6 ::/0 8080 MEDIUM", codes(rc("aws_security_group_rule", ["create"], None, {"type": "ingress", "ipv6_cidr_blocks": ["::/0"], "from_port": 8080, "to_port": 8080, "protocol": "tcp"})).get("PUBLIC_INGRESS") == "MEDIUM")
    t("gcp firewall 3389", codes(rc("google_compute_firewall", ["create"], None, {"direction": "INGRESS", "source_ranges": ["0.0.0.0/0"], "allow": [{"protocol": "tcp", "ports": ["3389"]}]})).get("PUBLIC_INGRESS") == "HIGH")
    t("azure nsg * HIGH", codes(rc("azurerm_network_security_rule", ["create"], None, {"direction": "Inbound", "access": "Allow", "source_address_prefix": "*", "destination_port_range": "*", "protocol": "Tcp"})).get("PUBLIC_INGRESS") == "HIGH")
    t("S3 public access block off", codes(rc("aws_s3_bucket_public_access_block", ["update"], {"block_public_policy": True}, {"block_public_policy": False})).get("S3_PUBLIC_ACCESS") == "HIGH")
    t("public-read ACL", "PUBLIC_ACL" in codes(rc("aws_s3_bucket_acl", ["create"], None, {"acl": "public-read"})))
    pol = lambda *st: json.dumps({"Version": "2012-10-17", "Statement": list(st)})
    t("admin policy HIGH", codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "*", "Resource": "*"})})).get("IAM_ADMIN") == "HIGH")
    t("PassRole on * HIGH", codes(rc("aws_iam_role_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": ["iam:PassRole"], "Resource": "*"})})).get("IAM_ESCALATION") == "HIGH")
    t("s3:* on bucket MEDIUM", codes(rc("aws_iam_role_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::b/*"})})).get("IAM_WILDCARD") == "MEDIUM")
    t("IAM_WIDENED lists new actions", "s3:DeleteObject on arn:aws:s3:::b/*" in next(f["problem"] for f in review_change(rc("aws_iam_role_policy", ["update"],
        {"policy": pol({"Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/*"})},
        {"policy": pol({"Effect": "Allow", "Action": ["s3:GetObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::b/*"})}))[1] if f["code"] == "IAM_WIDENED"))
    t("Deny statements ignored", "IAM_ADMIN" not in codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Deny", "Action": "*", "Resource": "*"})})))
    t("trust anyone HIGH", codes(rc("aws_iam_role", ["create"], None, {"assume_role_policy": pol({"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole"})})).get("TRUST_ANYONE") == "HIGH")
    t("trust with condition ok", "TRUST_ANYONE" not in codes(rc("aws_iam_role", ["create"], None, {"assume_role_policy": pol({"Effect": "Allow", "Principal": {"AWS": "*"}, "Action": "sts:AssumeRole", "Condition": {"StringEquals": {"aws:PrincipalOrgID": "o-1"}}})})))
    t("bucket policy Principal * HIGH", codes(rc("aws_s3_bucket_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::b/*"})})).get("PUBLIC_POLICY") == "HIGH")
    t("unknown policy INFO", "POLICY_UNKNOWN" in codes(rc("aws_iam_policy", ["create"], None, {}, change={"after_unknown": {"policy": True}})))
    t("sensitive policy skipped", "IAM_ADMIN" not in codes(rc("aws_iam_policy", ["create"], None, {"policy": pol({"Effect": "Allow", "Action": "*", "Resource": "*"})}, change={"after_sensitive": {"policy": True}})))
    t("AdministratorAccess attachment HIGH", codes(rc("aws_iam_role_policy_attachment", ["create"], None, {"policy_arn": "arn:aws:iam::aws:policy/AdministratorAccess"})).get("IAM_ADMIN") == "HIGH")
    t("gcp allUsers HIGH", "PUBLIC_IAM" in codes(rc("google_storage_bucket_iam_member", ["create"], None, {"member": "allUsers", "role": "roles/storage.objectViewer"})))
    t("not a plan rejected", raises(lambda: review_json({"foo": 1})))
    ch, fs, _ = review_json({"format_version": "1.2", "resource_changes": [rc("aws_db_instance", ["delete"], {}, None)], "resource_drift": [{"address": "aws_lb.a"}], "errored": False, "complete": True})
    t("json review with drift", ch == [("destroy", "aws_db_instance.x")] and {"DATA_LOSS", "DRIFT"} <= {f["code"] for f in fs})
    txt = """  # aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
      ~ engine = "postgres" -> "mysql" # forces replacement
    }

  # aws_instance.web[1] will be destroyed
  # (because index [1] is out of range for count)
  - resource "aws_instance" "web" {
    }

  # aws_lb_target_group.a must be replaced
+/- resource "aws_lb_target_group" "a" {
    }

Plan: 2 to add, 0 to change, 3 to destroy.
"""
    ch, fs, _ = review_text(txt)
    c = {f["code"] for f in fs}
    t("text mode actions", ch == [("replace (destroy first)", "aws_db_instance.main"), ("destroy", "aws_instance.web[1]"), ("replace (create first)", "aws_lb_target_group.a")])
    t("text mode findings", {"DATA_LOSS", "DESTROY", "REPLACE", "TEXT_MODE", "COUNT_INDEX_SHIFT"} <= c and "forced by engine" in next(f["problem"] for f in fs if f["code"] == "DATA_LOSS"))
    t("text mode rejects junk", raises(lambda: review_text("hello")))
    passed = sum(ok for _, ok in cases)
    for name, ok in cases:
        print(("PASS " if ok else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def raises(fn):
    try:
        fn()
    except ValueError:
        return True
    return False


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("plan", nargs="?", help="plan JSON (terraform show -json) or plan text (terraform plan -no-color)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.plan:
        ap.print_usage(sys.stderr); return 2
    try:
        raw = open(a.plan, encoding="utf-8").read()
        if raw.lstrip().startswith("{"):
            changes, findings, version = review_json(json.loads(raw))
        else:
            changes, findings, version = review_text(raw)
    except (OSError, UnicodeDecodeError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr); return 2
    counts, sev, verdict = summarize(changes, findings)
    if a.json:
        print(json.dumps({"terraform_version": version, "counts": counts, "severity": sev, "verdict": verdict,
                          "findings": findings, "changes": [{"action": k, "address": x} for k, x in changes]}, indent=2))
    else:
        print(render(a.plan, version, changes, findings))
    return 1 if sev["HIGH"] else 0


if __name__ == "__main__":
    sys.exit(main())
P@community_library
0
Recipe Scaling and Unit Converter
Text

Source: github.com/f/prompts.chat · Contributor: f

---
name: recipe-scaling-and-unit-converter
description: Scales a recipe by servings, a factor, or pan size (square, rectangle, round, loaf), converts between US cups and spoons, metric volume and grams with an ingredient density table, rounds to measurable amounts, and flags baking caveats such as fractional eggs, leavening at large factors, batter depth and bake time when the pan changes, tiny amounts, seasoning and cooking time. Includes a stdlib Python scaler with --self-test.
---

# Recipe Scaling and Unit Converter

You help home cooks and bakers make a recipe bigger, smaller or fit a different pan, in the units they actually use. You give amounts they can measure, show the factor and the conversions behind them, and warn about the things that do not scale in a straight line: eggs, leavening, pan depth, bake time, seasoning and cooking time.

## Files in this skill

- `scripts/scale_recipe.py` - reads an ingredients CSV, scales by servings, factor or pan area, converts to US, metric or grams, rounds to kitchen measures and lists caveats (Python 3 standard library only)
- `references/conversions-and-densities.md` - US/metric volume and weight, grams per cup for about 30 common ingredients, egg sizes, oven temperatures with fan and gas mark, and pan areas
- `references/baking-scaling-caveats.md` - what scales linearly, eggs, leavening, pan size and bake time, cooking (non-baking) caveats and rounding rules
- `templates/scaled-recipe.md` - the scaled recipe to hand back
- `examples/fudgy-brownies-8x8.csv` - brownies for an 8x8 pan (16 pieces), used in the example

## Workflow

### 1. Get the recipe and the goal
Ask for the ingredient list with amounts and units, the original yield or pan, and the target: servings, a factor, or a different pan. Ask whether it is baking, which units they want (cups, grams, or as written), and where the recipe is from if cup, tablespoon or egg sizes could differ (US, UK, Australia). Put the ingredients in a CSV with columns `quantity,unit,ingredient,note`.

### 2. Run the scaler
```
python3 scripts/scale_recipe.py examples/fudgy-brownies-8x8.csv --from-pan 8x8 --to-pan 9x13 --to weight --baking --oven 350F --time 20-25
python3 scripts/scale_recipe.py stew.csv --servings 4 10 --to metric
python3 scripts/scale_recipe.py cake.csv --factor 2 --from-pan round8 --to-pan round9 --baking
python3 scripts/scale_recipe.py --self-test
```
Pans: `8x8`, `9x13`, `20x30cm`, `round9`, `round23cm`, `loaf9x5`. With only pans, the factor is the area ratio. With a factor or servings plus pans, the script checks how the batter depth changes. Exit 1 means a HIGH caveat (batter depth changes by more than 1.5x), 2 means bad input.

### 3. Check and adjust
- Read every caveat and turn it into a concrete instruction (see `references/baking-scaling-caveats.md`): weigh the partial egg, bake in batches, change the pan, lower the oven, taste before adding all the salt.
- For ingredients without a density (NO_DENSITY), keep the volume or look the weight up; never guess for salt brands or leavening.
- Sanity-check the result: does the pan hold it (2/3 full for cakes)? Did rounding move a key ingredient by more than 5 %? If yes, give grams.

### 4. Deliver
Fill `templates/scaled-recipe.md`: factor, yield, pan, oven in °F and °C (fan), ingredients in the cook's units with the alternative in brackets, method changes, caveats and a doneness test instead of a fixed time.

## Example result
`examples/fudgy-brownies-8x8.csv` moved from an 8x8 to a 9x13 pan: factor x1.83 (64 to 117 sq in), same batter depth, so bake 350°F / 180°C (fan 160°C) and start checking at 20 minutes. In grams: butter 205 g, sugar 365 g, cocoa 52 g, flour 115 g, chocolate chips 235 g, salt 2.5 g, baking powder 2 g, vanilla 7.5 g. Eggs scale to 3.66 (MEDIUM EGG_FRACTION): use 3 eggs plus 33 g beaten egg. Doubling the recipe but baking it in a 9 in round pan (about the same area as 8x8) doubles the batter depth (HIGH DEPTH_CHANGE, exit 1).

## Rules

- Show the factor and how you got it (servings ratio or pan areas) before the amounts.
- Never multiply bake or cooking times by the factor; give a doneness test and an early check time.
- Keep salt and leavening ratios in baking; in cooking add about three quarters of scaled strong seasonings and taste.
- Prefer grams for baking and say that densities are averages; trust the author's grams when they exist.
- Ask before assuming US cups or egg sizes for recipes from other countries.
- Mention food safety for large batches (cool quickly in shallow containers) when scaling cooked dishes up a lot.
FILE:references/conversions-and-densities.md
# Conversions and densities

## Volume (US customary)
| Unit | ml | Also |
|---|---|---|
| 1 tsp | 4.93 | 1/3 tbsp |
| 1 tbsp | 14.79 | 3 tsp |
| 1 fl oz | 29.57 | 2 tbsp |
| 1 cup | 236.59 | 16 tbsp, 8 fl oz |
| 1 pint | 473.18 | 2 cups |
| 1 quart | 946.35 | 4 cups |

Other cups: metric cup (Australia, NZ, Canada recipes) = 250 ml; UK imperial pint = 568 ml; Australian tablespoon = 20 ml (4 tsp). Ask where a recipe is from when the cup or tablespoon size matters (baking, salt, leavening).

## Weight
| Unit | g |
|---|---|
| 1 oz | 28.35 |
| 1 lb | 453.6 (16 oz) |
| 1 stick butter (US) | 113 (1/2 cup, 8 tbsp) |

"oz" in a recipe is weight unless it says "fl oz". For water and milk 8 fl oz weighs about 8 oz; for flour it is about 4.4 oz.

## Grams per US cup (spoon-and-level, used by the script)
| Ingredient | g/cup | g/tbsp | g/tsp |
|---|---|---|---|
| All-purpose (plain) flour | 125 | 7.8 | 2.6 |
| Bread flour | 127 | 7.9 | |
| Cake flour | 114 | 7.1 | |
| Whole wheat flour | 113 | 7.1 | |
| Almond flour | 96 | 6 | |
| Cornstarch (cornflour) | 112 | 7 | |
| Granulated or caster sugar | 200 | 12.5 | 4.2 |
| Brown sugar, packed | 213 | 13.3 | |
| Powdered (icing) sugar | 120 | 7.5 | |
| Butter | 227 | 14.2 | |
| Water | 237 | 14.8 | 4.9 |
| Milk, buttermilk, sour cream | 242 | 15.1 | |
| Heavy cream | 238 | 14.9 | |
| Yogurt | 245 | 15.3 | |
| Vegetable or canola oil | 218 | 13.6 | |
| Olive oil | 216 | 13.5 | |
| Honey, golden syrup | 340 | 21 | 7 |
| Maple syrup | 322 | 20 | |
| Cocoa powder | 85 | 5.3 | |
| Rolled oats | 90 | | |
| Chocolate chips | 170 | | |
| Raw white rice | 185 | | |
| Chopped nuts / walnuts | 120 | | |
| Pecans | 110 | | |
| Raisins | 150 | | |
| Peanut butter | 270 | 17 | |
| Table (fine) salt | 288 | 18 | 6 |
| Baking soda | 230 | | 4.8 |
| Baking powder | 192 | | 4 |
| Instant or active dry yeast | 149 | | 3.1 |
| Vanilla extract | 202 | | 4.2 |
| Ground cinnamon | 125 | | 2.6 |

Notes:
- Flour scooped straight from the bag can weigh 140 to 150 g per cup. If a recipe author gives grams, trust the grams.
- Kosher salt depends on the brand: Diamond Crystal about 2.8 g/tsp, Morton about 5 g/tsp, table salt about 6 g/tsp. Swapping brands by volume can double the salt. The script leaves "kosher salt" in volume and says so.
- The table is a practical average; published tables differ by about 5 %. That is fine for cooking and most baking; for macarons, bread and candy, weigh.

## Eggs
| Size (US) | Out of shell |
|---|---|
| Large | 50 g (yolk 18 g, white 32 g), about 3 tbsp beaten |
| Medium | 44 g |
| Extra large | 56 g |
| Jumbo | 63 g |
UK/EU "large" (63 to 73 g in shell) is about 58 g out of the shell: a UK large is closer to a US extra large.

## Oven temperatures
| °F | °C | Fan °C | Gas mark |
|---|---|---|---|
| 300 | 150 | 130 | 2 |
| 325 | 160 | 140 | 3 |
| 350 | 180 | 160 | 4 |
| 375 | 190 | 170 | 5 |
| 400 | 200 | 180 | 6 |
| 425 | 220 | 200 | 7 |
| 450 | 230 | 210 | 8 |
Fan (convection) ovens: lower about 20 °C / 25 °F, or keep the temperature and check earlier.

## Pan areas
| Pan | Area (sq in) | Area (cm²) |
|---|---|---|
| 8x8 square | 64 | 413 |
| 9x9 square | 81 | 523 |
| 9x13 rectangle | 117 | 755 |
| 10x15 jelly roll | 150 | 968 |
| 8 in round | 50 | 324 |
| 9 in round | 64 | 410 |
| 10 in round | 79 | 507 |
| 9x5 loaf | 45 | 290 |
| 20 cm round | 49 | 314 |
| 23 cm round | 64 | 415 |
| 20x20 cm square | 62 | 400 |
Area of a round pan = 3.14 x radius². An 8x8 square and a 9 in round hold almost the same batter.
FILE:references/baking-scaling-caveats.md
# Scaling caveats

## What scales linearly
Flour, sugar, fat, liquids, chocolate, nuts, fruit and (by weight) eggs scale with the factor. In baking, salt and leavening also start at the linear amount: the chemistry depends on the ratio to flour, so keep the ratio.

## Eggs
- Fractions of an egg: beat the eggs, weigh, and use the needed grams (large egg = 50 g out of the shell). 3.66 eggs = 3 eggs + 33 g beaten egg (about 2 tbsp).
- If you round instead: more egg makes cakes and brownies firmer, cakier or richer; less egg makes them denser and more fragile. Round up for cakes, down for fudgy brownies and cookies when in doubt.
- Recipes written in another country may use a different egg size (see conversions).

## Leavening (baking powder, baking soda, yeast)
- Start linear and measure precisely; at small factors 1/8 tsp errors are large, so weigh on a 0.1 g scale.
- For factors above 2, mixing and oven heat do not scale: big batters are harder to mix evenly and the centre heats slowly. Make separate batches of the original size when you can.
- If the batter ends up deeper than the original (same pan, more batter), it rises more slowly in the centre. Do not "fix" this with more leavening; use a wider pan or lower the oven and bake longer.
- Bread: yeast scales linearly with flour; rising time depends on dough temperature, not quantity.

## Pan size and bake time
- Scaling by pan area keeps the batter depth the same, so the bake time stays about the same. Start checking 5 minutes early.
- Same recipe in a different pan: depth ratio = old area / new area. Deeper batter needs a longer bake at a lower temperature (about 25 °F / 15 °C lower); thinner batter bakes faster.
- Fill cake pans at most 2/3 full, brownie pans about halfway.
- Glass and dark metal pans bake faster at the edges: lower 25 °F / 15 °C.
- Cupcakes from a cake recipe: 18 to 22 minutes at the same temperature; a 9x13 cake recipe makes about 24 cupcakes.
- Never multiply the bake time by the factor. Use the doneness test from the recipe (toothpick with moist crumbs for brownies, clean for cake, 93 to 99 °C / 200 to 210 °F inside for bread).

## Cooking (not baking)
- Seasonings: add about three quarters of the scaled salt, chilli and strong spices, then taste. A bigger pot evaporates differently.
- Liquids in braises and soups: evaporation depends on the pot's surface, not the volume, so a doubled stew in a wider pot may need less extra liquid than 2x.
- Searing and frying: do it in batches; a crowded pan steams instead of browning.
- Time: a doubled pot takes longer to come to a boil and to cool, but simmering time is about the same once it is hot. Roasts depend on thickness, not weight: use a thermometer.
- Food safety: cool large batches quickly (split into shallow containers) and reheat to 74 °C / 165 °F.

## Rounding for real kitchens
- US measures: cups in 1/4 and 1/3 steps, then tbsp in halves, tsp down to 1/8. Below 1/8 tsp = a pinch.
- Metric: grams to 0.5 g below 10 g, 1 g below 100 g, 5 g below 1 kg.
- Keep the original unit system unless the cook asks; offer grams for baking.
- Check the result against the original ratios (for example flour to butter) after rounding; if rounding moved a key ingredient by more than 5 %, give the exact gram amount.
FILE:templates/scaled-recipe.md
# <Recipe name>, scaled <from 8x8 pan to 9x13 pan / from 4 to 10 servings>

**Factor:** x<1.83>   **Makes:** <24 brownies / 10 servings>   **Units:** <grams with US measures in brackets>
**Pan:** <9x13 in (23x33 cm), lined>   **Oven:** <350°F / 180°C (fan 160°C)>   **Bake:** <start checking at 20 min; usually 20-25>

## Ingredients
| Ingredient | Amount | US measure |
|---|---|---|
| <unsalted butter, melted> | <205 g> | <3/4 cup + 2 1/2 tbsp> |
| <large eggs> | <3 + 33 g beaten (about 2 tbsp)> | |

## Method changes from the original
- <Beat 4 eggs, weigh out 183 g, keep the rest for an egg wash>
- <Mix in a bigger bowl; fold flour in two additions>
- <Same bake time because the batter depth is the same; test at 20 min>

## Caveats
- <EGG_FRACTION: ...>
- <Kosher salt brand: ...>

## Doneness check
<Toothpick 2 cm from the centre comes out with moist crumbs, not wet batter.>
FILE:examples/fudgy-brownies-8x8.csv
quantity,unit,ingredient,note
1/2,cup,unsalted butter,melted
1,cup,granulated sugar,
2,each,large eggs,room temperature
1,tsp,vanilla extract,
1/3,cup,cocoa powder,unsweetened
1/2,cup,all-purpose flour,spoon and level
1/4,tsp,salt,
1/4,tsp,baking powder,
3/4,cup,chocolate chips,semisweet
FILE:scripts/scale_recipe.py
#!/usr/bin/env python3
"""Recipe scaling and unit converter (Python 3 standard library only).

Scales a recipe by servings, by a factor, or from one pan size to another, converts
between US volume, metric volume and weight (grams, using an ingredient density table),
rounds to amounts you can measure, and flags baking caveats: fractional eggs, leavening
at large factors, batter depth changes when the pan does not scale with the recipe,
tiny amounts, seasoning and cooking time.

Input CSV columns: quantity,unit,ingredient,note
  quantity: 2 | 1.5 | 3/4 | 1 1/2 | ½   unit: tsp tbsp cup fl oz pint quart ml l g kg oz lb stick
  each (or empty) for counted items such as eggs.
Usage:
  python3 scale_recipe.py recipe.csv --servings 4 10 [--to us|metric|weight|keep]
  python3 scale_recipe.py recipe.csv --from-pan 8x8 --to-pan 9x13 --baking --oven 350F --time 20-25
  python3 scale_recipe.py recipe.csv --factor 0.5 --to weight --json
  python3 scale_recipe.py --self-test
Pans: WxL (inches, add cm for centimetres: 20x30cm), round9 / round23cm, loaf9x5.
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = bad input.
"""
import argparse, csv, io, json, math, re, sys

ML = {"tsp": 4.92892, "tbsp": 14.7868, "cup": 236.588, "fl oz": 29.5735, "pint": 473.176,
      "quart": 946.353, "ml": 1.0, "l": 1000.0}
G = {"g": 1.0, "kg": 1000.0, "oz": 28.3495, "lb": 453.592, "stick": 113.4}
UNIT_ALIASES = {"teaspoon": "tsp", "teaspoons": "tsp", "t": "tsp", "tablespoon": "tbsp", "tablespoons": "tbsp",
                "tbs": "tbsp", "tbl": "tbsp", "T": "tbsp", "cups": "cup", "c": "cup", "floz": "fl oz",
                "fl. oz": "fl oz", "fluid ounce": "fl oz", "fluid ounces": "fl oz", "pints": "pint", "pt": "pint",
                "quarts": "quart", "qt": "quart", "milliliter": "ml", "milliliters": "ml", "mL": "ml",
                "liter": "l", "liters": "l", "litre": "l", "litres": "l", "L": "l", "gram": "g", "grams": "g",
                "kilogram": "kg", "kilograms": "kg", "ounce": "oz", "ounces": "oz", "pound": "lb", "pounds": "lb",
                "lbs": "lb", "sticks": "stick", "": "each", "x": "each", "whole": "each", "pc": "each",
                "pcs": "each", "piece": "each", "pieces": "each", "clove": "each", "cloves": "each",
                "large": "each", "medium": "each", "small": "each", "pinch": "pinch", "pinches": "pinch",
                "dash": "pinch"}
# grams per US cup (spoon-and-level). Longest matching name wins.
DENSITY = {
    "all-purpose flour": 125, "plain flour": 125, "flour": 125, "bread flour": 127, "cake flour": 114,
    "whole wheat flour": 113, "almond flour": 96, "cornstarch": 112, "cornflour": 112,
    "granulated sugar": 200, "caster sugar": 200, "sugar": 200, "brown sugar": 213,
    "powdered sugar": 120, "icing sugar": 120, "confectioners sugar": 120,
    "butter": 227, "water": 237, "milk": 242, "buttermilk": 242, "heavy cream": 238, "cream": 238,
    "sour cream": 242, "yogurt": 245, "vegetable oil": 218, "canola oil": 218, "olive oil": 216, "oil": 218,
    "honey": 340, "maple syrup": 322, "golden syrup": 340, "cocoa powder": 85, "cocoa": 85,
    "rolled oats": 90, "oats": 90, "chocolate chips": 170, "rice": 185, "chopped nuts": 120,
    "walnuts": 120, "pecans": 110, "raisins": 150, "peanut butter": 270,
    "table salt": 288, "fine salt": 288, "salt": 288, "kosher salt": None,
    "baking soda": 230, "bicarbonate of soda": 230, "baking powder": 192, "instant yeast": 149,
    "active dry yeast": 149, "yeast": 149, "vanilla extract": 202, "vanilla": 202,
    "ground cinnamon": 125, "cinnamon": 125,
}
LEAVENERS = ("baking soda", "bicarbonate", "baking powder", "yeast", "cream of tartar")
SEASONINGS = ("salt", "pepper", "chili", "chilli", "cayenne", "pepper flakes", "garlic powder", "curry", "cumin", "paprika")
EGG_G = {"egg": 50.0, "yolk": 18.0, "white": 32.0}  # US large egg out of shell
UNICODE_FRAC = {"½": .5, "¼": .25, "¾": .75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": .125, "⅜": .375, "⅝": .625, "⅞": .875}
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}


def parse_qty(s):
    s = s.strip()
    for ch, v in UNICODE_FRAC.items():
        if ch in s:
            rest = s.replace(ch, "").strip()
            return (float(rest) if rest else 0.0) + v
    m = re.fullmatch(r"(\d+)\s+(\d+)/(\d+)", s)
    if m:
        return int(m[1]) + int(m[2]) / int(m[3])
    m = re.fullmatch(r"(\d+)/(\d+)", s)
    if m:
        return int(m[1]) / int(m[2])
    m = re.fullmatch(r"\d+(?:\.\d+)?|\.\d+", s)
    if m:
        return float(s)
    raise ValueError(f"cannot read quantity {s!r}")


def norm_unit(u):
    u = u.strip()
    if u in UNIT_ALIASES:
        return UNIT_ALIASES[u]
    low = u.lower().rstrip(".")
    if low in ML or low in G or low in ("each", "pinch"):
        return low
    if low in UNIT_ALIASES:
        return UNIT_ALIASES[low]
    raise ValueError(f"unknown unit {u!r}")


def density_for(name):
    low = name.lower()
    best = None
    for key in DENSITY:
        if re.search(r"\b" + re.escape(key) + r"\b", low) and (best is None or len(key) > len(best)):
            best = key
    return (best, DENSITY[best]) if best else (None, None)


def egg_kind(name):
    low = name.lower()
    if "yolk" in low:
        return "yolk"
    if "white" in low and "egg" in low:
        return "white"
    if re.search(r"\beggs?\b", low):
        return "egg"
    return None


def parse_pan(spec):
    s = spec.strip().lower().replace(" ", "").replace("inch", "").replace("in", "").replace('"', "")
    cm = s.endswith("cm")
    if cm:
        s = s[:-2]
    k = 1 / 2.54 if cm else 1.0
    m = re.fullmatch(r"(?:round)?(\d+(?:\.\d+)?)(?:round)?", s)
    if m and "round" in s:
        d = float(m[1]) * k
        return math.pi * (d / 2) ** 2, f"{spec} round ({d:.1f} in)"
    m = re.fullmatch(r"(?:loaf|square|rect)?(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)", s)
    if m:
        a, b = float(m[1]) * k, float(m[2]) * k
        return a * b, f"{spec} ({a:.1f} x {b:.1f} in)"
    raise ValueError(f"cannot read pan size {spec!r} (use 9x13, 20x30cm, round9, round23cm, loaf9x5)")


def frac_str(x, denoms=(2, 3, 4, 8)):
    whole = int(x)
    rest = x - whole
    best = (abs(rest), 0, 1)
    for d in denoms:
        n = round(rest * d)
        if 0 < n < d and abs(rest - n / d) < best[0] - 1e-9:
            best = (abs(rest - n / d), n, d)
    if abs(rest - 1) < best[0]:
        return str(whole + 1)
    _, n, d = best
    if n == 0:
        return str(whole)
    g = math.gcd(n, d)
    return (f"{whole} " if whole else "") + f"{n // g}/{d // g}"


def frac_val(s):
    return parse_qty(s)


def cup_word(x):
    return "cups" if x > 1 + 1e-9 else "cup"


def fmt_us_volume(ml):
    cups = ml / ML["cup"]
    if cups >= 0.25 - 1e-9:
        s = frac_str(cups, (2, 3, 4))
        err = abs(frac_val(s) - cups) * ML["cup"]
        if err >= ML["tbsp"] * 0.75:
            base = math.floor(cups * 4) / 4
            tb = (cups - base) * ML["cup"] / ML["tbsp"]
            bs = frac_str(base, (4,))
            return (f"{bs} {cup_word(base)} + " if base else "") + f"{frac_str(tb, (2,))} tbsp"
        return f"{s} {cup_word(frac_val(s))}"
    tbsp = ml / ML["tbsp"]
    if tbsp >= 1 - 1e-9:
        return f"{frac_str(tbsp, (2,))} tbsp"
    tsp = ml / ML["tsp"]
    if tsp < 1 / 16:
        return "pinch"
    return f"{frac_str(tsp, (2, 4, 8))} tsp"


def round_metric(x):
    if x < 10:
        return round(x * 2) / 2
    if x < 100:
        return round(x)
    if x < 1000:
        return round(x / 5) * 5
    return round(x / 10) * 10


def fmt_num(x):
    return f"{x:g}"


def fmt_metric(amount, unit):
    v = round_metric(amount)
    if v >= 2000:
        return f"{fmt_num(v / 1000)} {'kg' if unit == 'g' else 'l'}"
    return f"{fmt_num(v)} {unit}"


def fmt_us_weight(g):
    oz = g / G["oz"]
    if oz >= 16:
        lb = oz / 16
        return f"{frac_str(lb, (4,))} lb"
    return f"{frac_str(oz, (2, 4))} oz" if oz >= 1 else f"{fmt_num(round(oz, 2))} oz"


def convert(row, factor, target):
    """Return dict with scaled display and notes for one ingredient row."""
    qty, unit, name = row["qty"] * factor, row["unit"], row["ingredient"]
    key, dens = density_for(name)
    egg = egg_kind(name)
    out = {"ingredient": name, "original": row["original"], "scaled_qty": qty, "unit": unit, "notes": []}
    if unit == "each":
        if egg:
            g = qty * EGG_G[egg]
            out["display"] = f"{fmt_num(round(qty, 2))} ({fmt_metric(g, 'g')} beaten)"
        else:
            out["display"] = fmt_num(round(qty, 2))
        return out
    if unit == "pinch":
        out["display"] = f"{fmt_num(round(qty, 2))} pinch"
        return out
    if unit in ML:
        ml = qty * ML[unit]
        out["ml"] = ml
        if target == "weight":
            if dens:
                out["display"] = fmt_metric(ml / ML["cup"] * dens, "g")
                out["also"] = fmt_us_volume(ml)
            else:
                out["display"] = fmt_metric(ml, "ml")
                out["also"] = fmt_us_volume(ml)
                out["notes"].append("NO_DENSITY")
        elif target == "metric" or (target == "keep" and unit in ("ml", "l")):
            out["display"] = fmt_metric(ml, "ml")
            out["also"] = fmt_us_volume(ml)
        else:
            out["display"] = fmt_us_volume(ml)
            out["also"] = fmt_metric(ml, "ml") if not dens else fmt_metric(ml / ML["cup"] * dens, "g")
        return out
    g = qty * G[unit]
    out["g"] = g
    if target == "us" or (target == "keep" and unit in ("oz", "lb", "stick")):
        if unit == "stick" and target == "keep":
            out["display"] = f"{frac_str(qty, (2, 4))} stick ({fmt_metric(g, 'g')})"
        else:
            out["display"] = fmt_us_weight(g)
        out["also"] = fmt_metric(g, "g")
    else:
        out["display"] = fmt_metric(g, "g")
        if dens:
            out["also"] = fmt_us_volume(g / dens * ML["cup"])
    return out


def read_recipe(text):
    rows = []
    reader = csv.DictReader(io.StringIO(text.lstrip("\ufeff")))
    need = {"quantity", "unit", "ingredient"}
    if not reader.fieldnames or not need <= {f.strip().lower() for f in reader.fieldnames}:
        raise ValueError("CSV header must include quantity,unit,ingredient (note optional)")
    for i, r in enumerate(reader, 2):
        r = {k.strip().lower(): (v or "").strip() for k, v in r.items() if k}
        if not r.get("ingredient"):
            continue
        try:
            qty = parse_qty(r["quantity"])
            unit = norm_unit(r.get("unit", ""))
        except ValueError as e:
            raise ValueError(f"line {i}: {e}")
        orig = f"{r['quantity']} {r.get('unit', '')}".strip()
        rows.append({"qty": qty, "unit": unit, "ingredient": r["ingredient"], "note": r.get("note", ""), "original": orig})
    if not rows:
        raise ValueError("no ingredient rows")
    return rows


def oven(temp):
    m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*°?\s*([CcFf])\s*", temp)
    if not m:
        raise ValueError(f"cannot read oven temperature {temp!r} (use 350F or 180C)")
    v, u = float(m[1]), m[2].upper()
    if u == "F":
        c = (v - 32) * 5 / 9
        return f"{v:g}°F = {round(c / 10) * 10:g}°C (fan oven {round(c / 10) * 10 - 20:g}°C)"
    f = v * 9 / 5 + 32
    return f"{v:g}°C = {round(f / 25) * 25:g}°F"


def scale(rows, factor, target="keep", baking=False, depth_ratio=None, pan_change=None, bake_time=None):
    items = [convert(r, factor, target) for r in rows]
    findings = []
    for it, r in zip(items, rows):
        name = r["ingredient"].lower()
        egg = egg_kind(name)
        if egg and r["unit"] == "each":
            q = it["scaled_qty"]
            whole = math.floor(q + 1e-9)
            if abs(q - round(q)) > 0.15:
                extra = (q - whole) * EGG_G[egg]
                findings.append(("MEDIUM", "EGG_FRACTION", r["ingredient"],
                                 f"scales to {q:.2f} {egg}s",
                                 f"Use {whole} whole {egg}{'s' if whole != 1 else ''} plus {round(extra):g} g beaten {egg} (a large {egg} is about {EGG_G[egg]:g} g out of the shell), or round to {round(q)} and expect a {'richer' if round(q) > q else 'drier'} result."))
        if "NO_DENSITY" in it["notes"]:
            findings.append(("LOW", "NO_DENSITY", r["ingredient"], "no density in the table, so it stays in volume",
                             "Weigh it once, or look up grams per cup; for kosher salt the brand matters (Diamond Crystal about 136 g/cup, Morton about 240 g/cup)."))
        ml = it.get("ml")
        if ml is not None and ml / ML["tsp"] < 1 / 8 and r["unit"] in ML:
            findings.append(("LOW", "TINY_AMOUNT", r["ingredient"], f"scales to {ml / ML['tsp']:.2f} tsp",
                             "Use a pinch or a 1/8 tsp measure; for leavening weigh on a 0.1 g scale."))
        if baking and any(w in name for w in LEAVENERS) and (factor > 2 or factor < 0.5):
            findings.append(("LOW", "LEAVENING", r["ingredient"], f"leavening scaled x{factor:.2f}",
                             "Linear scaling is the starting point; measure exactly (weigh), and for factors above 2 consider separate batches because mixing and oven heat do not scale."))
        if not baking and factor > 2 and any(w in name for w in SEASONINGS):
            findings.append(("INFO", "SEASONING", r["ingredient"], f"seasoning scaled x{factor:.2f}",
                             "Add about three quarters, then taste and adjust; evaporation and pot size change how salty or hot it ends up."))
    if baking and (factor > 3 or factor < 1 / 3):
        findings.append(("MEDIUM", "BIG_FACTOR", "-", f"baking recipe scaled x{factor:.2f}",
                         "Bake in several batches of the original size, or check mixer bowl capacity; never multiply the bake time."))
    if depth_ratio is not None:
        sev = "HIGH" if (depth_ratio > 1.5 or depth_ratio < 0.67) else ("MEDIUM" if (depth_ratio > 1.25 or depth_ratio < 0.8) else None)
        if sev and baking:
            if depth_ratio > 1:
                fix = "Batter will be deeper: lower the oven by 25°F/15°C, bake longer and test the centre, keep the pan at most 2/3 full, or use a bigger pan."
            else:
                fix = "Batter will be thinner: start checking 25 to 40 % earlier, or use a smaller pan."
            findings.append((sev, "DEPTH_CHANGE", "-", f"batter depth changes x{depth_ratio:.2f} versus the original pan", fix))
    if baking and pan_change:
        if depth_ratio is None or 0.9 <= depth_ratio <= 1.1:
            tip = "Batter depth stays about the same, so the bake time stays about the same; start checking 5 minutes early. Glass or dark pans bake faster: lower 25°F/15°C."
        elif 0.8 <= depth_ratio <= 1.25:
            tip = f"Batter depth changes slightly (x{depth_ratio:.2f}); {'bake a few minutes longer' if depth_ratio > 1 else 'start checking a few minutes early'} and test the centre."
        else:
            tip = "See DEPTH_CHANGE for time and temperature."
        if bake_time:
            tip = f"Original {bake_time} min. " + tip
        findings.append(("INFO", "PAN_TIME", "-", pan_change, tip))
    if not baking and factor > 1.5:
        findings.append(("INFO", "COOK_TIME", "-", f"scaled x{factor:.2f}",
                         "Cooking time does not scale with quantity: brown meat in batches, allow longer to come to a boil, and use a pot that keeps the depth similar."))
    findings.sort(key=lambda f: SEV_ORDER[f[0]])
    return items, [dict(zip(("severity", "code", "ingredient", "problem", "fix"), f)) for f in findings]


def render(path, factor, how, target, items, findings, oven_line=None):
    out = [f"# Scaled recipe: {path}", "", f"Scale: {how} -> factor x{factor:.3f}   Units: {target}", ""]
    out += ["| Ingredient | Original | Scaled | Also |", "|---|---|---|---|"]
    for it in items:
        out.append(f"| {it['ingredient']} | {it['original']} | {it['display']} | {it.get('also', '')} |")
    if oven_line:
        out += ["", f"Oven: {oven_line}"]
    out += ["", "## Notes", ""]
    if findings:
        out += ["| Severity | Code | Ingredient | Problem | Advice |", "|---|---|---|---|---|"]
        for f in findings:
            out.append(f"| {f['severity']} | {f['code']} | {f['ingredient']} | {f['problem']} | {f['fix']} |")
    else:
        out.append("No caveats.")
    out += ["", "Weights for volume ingredients use spoon-and-level densities; weigh flour if you can."]
    return "\n".join(out)


def self_test():
    cases = []
    def t(name, cond):
        cases.append((name, bool(cond)))
    close = lambda a, b, tol=1e-6: abs(a - b) <= tol
    t("parse 1 1/2", close(parse_qty("1 1/2"), 1.5))
    t("parse 3/4 and .5 and ½", close(parse_qty("3/4"), .75) and close(parse_qty(".5"), .5) and close(parse_qty("1½"), 1.5))
    t("bad quantity rejected", raises(lambda: parse_qty("some")))
    t("unit aliases", norm_unit("Tablespoons") == "tbsp" and norm_unit("T") == "tbsp" and norm_unit("t") == "tsp" and norm_unit("") == "each")
    t("unknown unit rejected", raises(lambda: norm_unit("handful")))
    t("longest density match", density_for("light brown sugar")[0] == "brown sugar" and density_for("all-purpose flour")[1] == 125)
    t("salt vs kosher salt", density_for("salt")[1] == 288 and density_for("kosher salt")[1] is None)
    t("eggs detected", egg_kind("large eggs") == "egg" and egg_kind("egg yolks") == "yolk" and egg_kind("eggplant") is None)
    a8 = parse_pan("8x8")[0]; a913 = parse_pan("9x13")[0]
    t("rect pan area", close(a8, 64) and close(a913, 117))
    t("round pan area", close(parse_pan("round9")[0], math.pi * 20.25))
    t("cm pan", close(parse_pan("20x20cm")[0], (20 / 2.54) ** 2))
    t("bad pan rejected", raises(lambda: parse_pan("big")))
    t("us volume 1 cup", fmt_us_volume(236.588) == "1 cup")
    t("us volume 3/4 cup", fmt_us_volume(ML["cup"] * .75) == "3/4 cup")
    t("us volume 2/3 cup", fmt_us_volume(ML["cup"] * 2 / 3) == "2/3 cup")
    t("us volume cup + tbsp", fmt_us_volume(ML["cup"] * 1.125) == "1 cup + 2 tbsp")
    t("fractions reduced, cups plural", fmt_us_volume(ML["cup"] * 0.61) == "1/2 cup + 2 tbsp" and fmt_us_volume(ML["cup"] * 2) == "2 cups")
    t("us volume tbsp", fmt_us_volume(ML["tbsp"] * 1.5) == "1 1/2 tbsp")
    t("us volume tsp", fmt_us_volume(ML["tsp"] * .5) == "1/2 tsp")
    t("us volume pinch", fmt_us_volume(ML["tsp"] * .05) == "pinch")
    t("metric rounding", round_metric(7.3) == 7.5 and round_metric(57.4) == 57 and round_metric(228) == 230 and round_metric(1234) == 1230)
    t("1 cup flour = 125 g", convert({"qty": 1, "unit": "cup", "ingredient": "all-purpose flour", "original": ""}, 1, "weight")["display"] == "125 g")
    t("1 stick butter in us/keep", "1 stick" in convert({"qty": 1, "unit": "stick", "ingredient": "butter", "original": ""}, 1, "keep")["display"])
    t("8 oz to grams", convert({"qty": 8, "unit": "oz", "ingredient": "cheese", "original": ""}, 1, "metric")["display"] == "225 g")
    t("500 g to us lb", convert({"qty": 500, "unit": "g", "ingredient": "pasta", "original": ""}, 1, "us")["display"] == "1 lb")
    t("metric volume", convert({"qty": 1, "unit": "cup", "ingredient": "milk", "original": ""}, 2, "metric")["display"] == "475 ml")
    t("no density keeps ml", "NO_DENSITY" in convert({"qty": 1, "unit": "tbsp", "ingredient": "fish sauce", "original": ""}, 1, "weight")["notes"])
    rows = read_recipe("quantity,unit,ingredient,note\n2,each,large eggs,\n1/4,tsp,baking soda,\n1,cup,sugar,\n")
    t("csv read", len(rows) == 3 and rows[0]["unit"] == "each")
    t("csv bad header rejected", raises(lambda: read_recipe("a,b\n1,2\n")))
    t("csv bad unit names line", "line 2" in err_of(lambda: read_recipe("quantity,unit,ingredient\n1,handful,x\n")))
    _, f = scale(rows, 1.828, baking=True)
    t("EGG_FRACTION at x1.83", any(x["code"] == "EGG_FRACTION" and "3 whole eggs plus 33 g" in x["fix"] for x in f))
    _, f = scale(rows, 2.0, baking=True)
    t("no EGG_FRACTION when whole", not any(x["code"] == "EGG_FRACTION" for x in f))
    _, f = scale(rows, 0.1, baking=True)
    t("TINY_AMOUNT and BIG_FACTOR", {"TINY_AMOUNT", "BIG_FACTOR", "LEAVENING"} <= {x["code"] for x in f})
    _, f = scale(rows, 1.0, baking=True, depth_ratio=1.83)
    t("DEPTH_CHANGE HIGH", any(x["code"] == "DEPTH_CHANGE" and x["severity"] == "HIGH" for x in f))
    _, f = scale(rows, 1.0, baking=True, depth_ratio=1.3)
    t("DEPTH_CHANGE MEDIUM", any(x["code"] == "DEPTH_CHANGE" and x["severity"] == "MEDIUM" for x in f))
    _, f = scale(read_recipe("quantity,unit,ingredient\n1,tsp,salt\n"), 3, baking=False)
    t("SEASONING and COOK_TIME for cooking", {"SEASONING", "COOK_TIME"} <= {x["code"] for x in f})
    t("oven F to C", oven("350F") == "350°F = 180°C (fan oven 160°C)" and oven("200C") == "200°C = 400°F")
    t("findings sorted HIGH first", scale(rows, 1.83, baking=True, depth_ratio=2)[1][0]["severity"] == "HIGH")
    passed = sum(ok for _, ok in cases)
    for name, ok in cases:
        print(("PASS " if ok else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def raises(fn):
    try:
        fn()
    except ValueError:
        return True
    return False


def err_of(fn):
    try:
        fn()
    except ValueError as e:
        return str(e)
    return ""


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("recipe", nargs="?")
    g = ap.add_mutually_exclusive_group()
    g.add_argument("--servings", nargs=2, type=float, metavar=("FROM", "TO"))
    g.add_argument("--factor", type=float)
    ap.add_argument("--from-pan")
    ap.add_argument("--to-pan")
    ap.add_argument("--to", default="keep", choices=["keep", "us", "metric", "weight"])
    ap.add_argument("--baking", action="store_true", help="apply baking caveats (eggs, leavening, pan depth)")
    ap.add_argument("--oven", help="original oven temperature, e.g. 350F or 180C")
    ap.add_argument("--time", help="original bake time in minutes, e.g. 20-25")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.recipe:
        ap.print_usage(sys.stderr); return 2
    try:
        rows = read_recipe(open(a.recipe, encoding="utf-8").read())
        depth_ratio = pan_change = None
        if bool(a.from_pan) != bool(a.to_pan):
            raise ValueError("give both --from-pan and --to-pan")
        if a.from_pan:
            fa, fd = parse_pan(a.from_pan)
            ta, td = parse_pan(a.to_pan)
            pan_change = f"{fd} -> {td}, area x{ta / fa:.2f}"
        if a.servings:
            if a.servings[0] <= 0 or a.servings[1] <= 0:
                raise ValueError("servings must be positive")
            factor, how = a.servings[1] / a.servings[0], f"{a.servings[0]:g} -> {a.servings[1]:g} servings"
        elif a.factor is not None:
            if a.factor <= 0:
                raise ValueError("factor must be positive")
            factor, how = a.factor, f"factor {a.factor:g}"
        elif a.from_pan:
            factor, how = ta / fa, f"pan {a.from_pan} -> {a.to_pan}"
        else:
            raise ValueError("give --servings FROM TO, --factor F or --from-pan/--to-pan")
        if a.from_pan:
            depth_ratio = factor * fa / ta
            if abs(depth_ratio - 1) < 0.01:
                depth_ratio = None
        items, findings = scale(rows, factor, a.to, a.baking, depth_ratio, pan_change, a.time)
        oven_line = oven(a.oven) if a.oven else None
    except (OSError, UnicodeDecodeError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr); return 2
    if a.json:
        print(json.dumps({"factor": factor, "how": how, "units": a.to, "oven": oven_line,
                          "items": [{k: v for k, v in it.items() if k != "notes"} for it in items],
                          "findings": findings}, indent=2, ensure_ascii=False))
    else:
        print(render(a.recipe, factor, how, a.to, items, findings, oven_line))
    return 1 if any(f["severity"] == "HIGH" for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
P@community_library
0
Gitignore Pattern Tester and Reviewer
Text

Source: github.com/f/prompts.chat · Contributor: f

---
name: gitignore-pattern-tester-and-reviewer
description: Explains every line of a .gitignore, tests it against paths that must be ignored or must stay committable, names the rule that decides each path, and flags mistakes such as negations blocked by an excluded parent folder, inline comments, leading spaces, backslashes, stray **, secrets not covered and .env.example ignored. Returns a fixed file. Includes a stdlib Python checker with --self-test, matched against real git.
---

# Gitignore Pattern Tester and Reviewer

You help developers understand and fix `.gitignore` files. You explain what each pattern really matches, prove it with path tests, and find the mistakes that make git ignore source files or commit secrets and build output. You return a corrected file with tests that pass, not just advice.

## Files in this skill

- `scripts/gitignore_check.py` - explains each rule, runs "+ path" (must be ignored) and "- path" (must be kept) tests, names the deciding line for each path, and reports HIGH, MEDIUM, LOW and INFO findings (Python 3 standard library only)
- `references/gitignore-rules.md` - how git reads ignore files: sources and priority, line syntax, anchoring, wildcards, the parent-directory rule, tracked files and commands to confirm
- `references/common-pitfalls.md` - every finding code with its fix, pitfalls the script cannot see (broad names, tracked files, nested files, case sensitivity, lock files, .dockerignore differences) and good starting blocks
- `templates/gitignore-review.md` - the review to hand back
- `examples/webapp.gitignore` - a web app ignore file with six real bugs
- `examples/webapp-fixed.gitignore` - the corrected file (all tests pass)
- `examples/webapp-paths.txt` - 19 path tests used with both files

## Workflow

### 1. Collect the inputs
Ask for the `.gitignore` (and any nested ones), the stack, and the paths that matter: what must be ignored (build output, dependencies, logs, secrets) and what must stay committable (source folders with common names like `build/` or `lib/`, `.gitkeep` files, `.env.example`, lock files). If the user only asks "why is X ignored?" ask for the path and use `git check-ignore -v X` output if they have it.

### 2. Run the checker
```
python3 scripts/gitignore_check.py examples/webapp.gitignore --test examples/webapp-paths.txt
python3 scripts/gitignore_check.py .gitignore --test paths.txt --json
python3 scripts/gitignore_check.py --self-test
```
Paths file: `+ path` must be ignored, `- path` must be kept, end directories with `/`. Exit 1 means a HIGH finding or a failed test, 2 means unreadable input. The checker models one ignore file at the repo root; for a nested file, give paths relative to its folder.

### 3. Explain and fix
- Walk through the Rules table in plain words, especially anchoring (slash at start or middle = relative to the .gitignore folder) and the parent-directory rule (see `references/gitignore-rules.md`).
- For each FAIL, quote the deciding line from the "Decided by" column and give the smallest change that fixes it.
- Fix every HIGH and MEDIUM finding; explain any INFO you leave in place.
- Add `- path` tests for source folders with common names so over-broad rules show up.
- Re-run the checker on the fixed file until all tests pass, and show the diff.

### 4. Deliver
Fill `templates/gitignore-review.md`: what the current file does, the test table, findings, the full fixed file, a diff, and the commands to confirm (`git check-ignore -v`, `git status --ignored`) and to untrack files that were already committed (`git rm --cached`).

## Example result
For `examples/webapp.gitignore` with `examples/webapp-paths.txt`: 13/19 tests pass, 1 HIGH, 4 MEDIUM, 2 LOW, 1 INFO. HIGH NEGATION_BLOCKED: `logs/` excludes the folder, so `!logs/.gitkeep` can never apply (fix: `logs/*`). MEDIUM: `config/secrets.yml # never commit` is one pattern, so the secrets file would be committed; ` Thumbs.db` has a leading space; `src\generated` is read as `srcgenerated`; and `*.key`, `*.pem` and `id_rsa` files are not covered. LOW: `.env*` hides `.env.example`; `*.log` is listed twice. The unanchored `build` also hides `src/build/README.md` (fix: `/build/`). `examples/webapp-fixed.gitignore` passes 19/19 with no HIGH, MEDIUM or LOW findings.

## Rules

- Last matching line wins; a file inside an excluded folder can never be re-included. Say this whenever a `!` line is involved.
- Never suggest ignoring lock files (`package-lock.json`, `poetry.lock`, `go.sum`, app `Cargo.lock`) or shared config the team commits on purpose.
- If a secret was already committed, say that ignoring it is not enough: rotate it and consider rewriting history.
- Prefer anchored root patterns (`/dist/`, `/build/`) for output folders, and put personal editor and OS files in the global ignore file when the team agrees.
- Confirm on the real repository with `git check-ignore -v`: the script follows git's documented rules for one file, but nested files, `.git/info/exclude`, the global file and `core.ignorecase` can change the result.
FILE:references/gitignore-rules.md
# How git reads .gitignore

Source: the gitignore(5) manual (`git help gitignore`). The checker implements these rules and was compared with real git 2.47 on 6,956 path checks with no differences.

## Where rules come from (highest priority first)
1. Command-line `--exclude` options (rare; used by some tools).
2. `.gitignore` files: the one in the same folder as the path wins over the ones above it. Patterns are relative to the folder that holds that `.gitignore`.
3. `.git/info/exclude` - local to one clone, never committed. Good for personal scratch files.
4. The global file in `core.excludesFile` (default `~/.config/git/ignore`) - good for editor and OS files such as `.DS_Store` and `*.swp`.

Inside one file, the LAST matching line decides.

## Line syntax
| Line | Meaning |
|---|---|
| empty | ignored, use as a separator |
| `# text` | comment; only at the very start of a line. `foo # bar` is the pattern `foo # bar` |
| `\#file` | a name that starts with `#` |
| `!pattern` | re-include something an earlier line excluded |
| `\!file` | a name that starts with `!` |
| `name   ` | trailing spaces are dropped unless escaped (`name\ `) |
| `  name` | leading spaces are KEPT and become part of the name |
| `\` | escapes the next character; it is never a path separator |

## Slashes decide where a pattern matches
| Pattern | Matches | Does not match |
|---|---|---|
| `build` | `build`, `src/build`, `a/b/build/x.js` (any file or folder named build) | `build.sh` |
| `build/` | folders named build at any depth | a FILE named build |
| `/build` | `build` next to the .gitignore only | `src/build` |
| `doc/frotz` | `doc/frotz` next to the .gitignore (a slash in the middle anchors like a leading slash) | `a/doc/frotz` |
| `**/doc/frotz` | `doc/frotz` at any depth | |

Rule of thumb: no slash (or only a trailing one) = any depth; a slash at the start or middle = relative to the .gitignore folder.

## Wildcards
| Wildcard | Meaning |
|---|---|
| `*` | any characters except `/` (`*.log`, `/a*z` does not match `ab/cz`) |
| `?` | exactly one character except `/` |
| `[abc]`, `[a-z]`, `[!0-9]`, `[^0-9]` | one character from (or not from) the set; `[[:digit:]]` style classes work |
| `**/name` | `name` in any folder, including the top |
| `dir/**` | everything inside `dir`, but not `dir` itself |
| `a/**/b` | `a/b`, `a/x/b`, `a/x/y/b` |
| `foo**`, `**bar` | `**` not next to slashes acts like a single `*` |

## The parent-directory rule (the most common surprise)
"It is not possible to re-include a file if a parent directory of that file is excluded." Git does not even list the contents of an excluded folder, so a later `!` line never gets a chance.

```gitignore
# does NOT work: logs/ is excluded as a folder
logs/
!logs/.gitkeep

# works: exclude the folder's contents, keep the folder visible
logs/*
!logs/.gitkeep

# works for deeper files: re-include each folder level
/assets/*
!/assets/icons/
/assets/icons/*
!/assets/icons/app.svg
```

## Whitelist style
Ignore everything, then allow what you want:
```gitignore
*
!*/
!*.md
!src/**
```
`!*/` re-includes every folder so the later `!` lines can reach files inside them.

## Already tracked files
.gitignore only affects untracked files. A file that is already committed stays tracked:
```
git rm --cached path/to/file        # keep it on disk, stop tracking it
git rm -r --cached build/           # same for a folder
```
If it was a secret, removing it from tracking is not enough: rotate the secret, because it is still in history.

## Commands to confirm
```
git check-ignore -v path/to/file          # prints the file, line and pattern that ignores it (nothing = not ignored)
git check-ignore -v --no-index path       # also for files that are tracked
git status --ignored --short              # lists ignored files as "!!"
git ls-files --others --ignored --exclude-standard   # every ignored untracked file
```
FILE:references/common-pitfalls.md
# Common .gitignore pitfalls and the checker's codes

| Code | Severity | What goes wrong | Fix |
|---|---|---|---|
| NEGATION_BLOCKED | HIGH | `!path/file` after a rule that excludes a parent folder (`logs/`, `dist`, `a/*` for `a/b/c`) never takes effect, so the file you meant to commit stays ignored | Exclude contents (`logs/*` or `logs/**`) instead of the folder, or re-include each folder level (`!a/b/`) |
| SECRETS_NOT_COVERED | MEDIUM | typical secret files (`.env`, `*.pem`, `*.key`, `id_rsa`) would be committed by `git add .` | Add `.env`, `.env.*`, `*.pem`, `*.key`, `id_rsa*`; also use a secret scanner in CI |
| INLINE_COMMENT | MEDIUM | `secrets.yml # never commit` is one pattern including the comment text, so `secrets.yml` is NOT ignored | Put comments on their own line |
| LEADING_SPACE | MEDIUM | ` Thumbs.db` matches only a name that starts with a space | Remove the space |
| BACKSLASH_SEPARATOR | MEDIUM | `src\generated` means "src" + escaped "g" + "enerated" = `srcgenerated` | Always use `/`, also on Windows |
| ENV_EXAMPLE_IGNORED | LOW | `.env*` also hides `.env.example`, the template other developers need | Add `!.env.example` after it |
| DUPLICATE | LOW | the same line twice; harmless but a sign of copy-paste merges | Delete one |
| DOUBLE_STAR | LOW | `foo**` is just `foo*`; the author probably meant `foo/**` | Use `**` only as `**/x`, `x/**` or `a/**/b` |
| TRAILING_SPACE | LOW | trailing spaces are silently dropped; if they were meant, the file will not match | Remove or escape them |
| CRLF | LOW | Windows line endings in .gitignore | Save as LF; add `* text=auto` to .gitattributes |
| ANCHORED_BY_SLASH | INFO | `src/gen` only matches next to the .gitignore, not `packages/a/src/gen` | Add `/` to show intent or `**/` to match at any depth |

## Pitfalls the script cannot see (check them by hand)
- **Unanchored names that are too broad.** `build` also hides `src/build/` and `docs/build/`; `lib` hides every `lib` folder including source code. Use `/build/` for the root output folder. Write a `- path` test for any source folder with a common name.
- **Tracked files.** Adding a rule does not untrack files already committed. Run `git ls-files -ci --exclude-standard` to list tracked files that match the ignore rules, then `git rm --cached`.
- **Nested .gitignore files.** A `.gitignore` in a subfolder adds rules relative to that subfolder and can re-include what the root file excluded (unless a parent folder is excluded). Run the checker on each file with paths relative to its folder, or confirm with `git check-ignore -v`.
- **Case sensitivity.** On macOS and Windows `core.ignorecase=true` makes `*.JPG` match `photo.jpg`; on Linux CI it does not. Write both cases or use `*.[jJ][pP][gG]`.
- **Personal files in the shared file.** `.idea/`, `.vscode/`, `*.swp`, `.DS_Store` belong in the global ignore file (`core.excludesFile`), though many teams keep them in the repo file for convenience. Do not ignore `.vscode/settings.json` if the team shares it on purpose.
- **Lock files.** Do not ignore `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `poetry.lock`, `Cargo.lock` (for applications) or `go.sum`: they make builds reproducible.
- **Ignoring then committing anyway.** `git add -f` overrides ignore rules; reviewers should check for force-added build output or secrets.
- **Other ignore files use different rules.** `.dockerignore` uses Go filepath.Match plus `**` and is always anchored at the build context root (no "any depth" for plain names). `.npmignore`, `.eslintignore` and `.prettierignore` follow gitignore-like rules but not always every detail. Test them with their own tools.

## Good starting blocks
```gitignore
# Node
node_modules/
/dist/
/build/
npm-debug.log*
.npm/

# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
/dist/
*.egg-info/

# Secrets
.env
.env.*
!.env.example
*.pem
*.key
id_rsa*

# Logs that keep their folder
logs/*
!logs/.gitkeep
```
FILE:templates/gitignore-review.md
# .gitignore review: <repository or folder>

**Stack:** <Node 20 + Vite / Python 3.12 / monorepo with pnpm workspaces>
**Files reviewed:** <.gitignore at root, packages/api/.gitignore>
**Summary:** <one sentence: the most important problem and what the fix changes>

## What the current file does
| Line | Pattern | Meaning |
|---|---|---|
| <9> | <`logs/`> | <ignores every folder named logs, at any depth, and everything in it> |

## Path tests
| Result | Path | Expected | Actual | Decided by |
|---|---|---|---|---|
| <FAIL> | <`logs/.gitkeep`> | <kept> | <ignored> | <parent logs/ excluded by L9; L10 cannot re-include it> |

## Findings
| # | Severity | Code | Line | Problem | Fix |
|---|---|---|---|---|---|
| 1 | <HIGH> | <NEGATION_BLOCKED> | <10> | <...> | <use logs/* instead of logs/> |

## Proposed .gitignore
```gitignore
<full fixed file, with comments on their own lines>
```

## Changes
```diff
- logs/
+ logs/*
```

## Apply and confirm
1. Replace the file, then run: `git check-ignore -v <each path from the tests>`
2. `git status --ignored --short` and check nothing important shows as `!!`
3. Untrack files that are now ignored but were committed: `git rm --cached <path>` (rotate any secret that was committed)
4. Commit: `chore: fix .gitignore (<short reason>)`

## Follow-ups
- <move editor files to the global ignore file>
- <add a secret scanner (gitleaks, trufflehog) to CI>
FILE:examples/webapp.gitignore
# Dependencies
node_modules
/vendor/

# Build output
dist/
build
*.log
logs/
!logs/.gitkeep

# Environment
.env*
config/secrets.yml # never commit

# OS and editor files
.DS_Store
.idea/
*.swp
 Thumbs.db

# Test coverage
coverage/**
!coverage/README.md

# Generated code
src\generated
*.log
FILE:examples/webapp-fixed.gitignore
# Dependencies
node_modules/
/vendor/

# Build output (root only, so src/build/ stays tracked)
/dist/
/build/
*.log
# ignore the contents of logs/, not the folder, so the negation can work
logs/*
!logs/.gitkeep

# Environment and secrets
.env
.env.*
!.env.example
*.pem
*.key
id_rsa*
# never commit
config/secrets.yml

# OS and editor files
.DS_Store
Thumbs.db
.idea/
*.swp

# Test coverage
coverage/**
!coverage/README.md

# Generated code
/src/generated/
FILE:examples/webapp-paths.txt
# Paths for examples/webapp.gitignore and webapp-fixed.gitignore
# "+ path" = must be ignored, "- path" = must stay committable
+ node_modules/react/index.js
+ packages/api/node_modules/lodash/lodash.js
+ vendor/autoload.php
- packages/vendor/README.md
+ dist/app.js
+ build/output.js
- src/build/README.md
+ logs/app.log
- logs/.gitkeep
+ .env
+ .env.local
- .env.example
+ config/secrets.yml
+ .DS_Store
+ Thumbs.db
+ coverage/lcov.info
- coverage/README.md
+ src/generated/types.ts
- src/index.ts
FILE:scripts/gitignore_check.py
#!/usr/bin/env python3
"""Gitignore pattern tester and reviewer (Python 3 standard library only).

Explains every rule in a .gitignore file, tests the file against paths that must be
ignored ("+ path") or must stay committable ("- path"), names the rule that decides
each path, and flags common mistakes: negations that cannot work because a parent
directory is excluded, inline comments, leading spaces, Windows backslashes, stray
"**", duplicates, secrets that are not covered and .env.example being ignored.

Matching follows git's documented gitignore rules for a single .gitignore at the
repository root: last matching rule wins, "!" re-includes, a trailing "/" matches
directories only, a "/" at the start or in the middle anchors the pattern to the
.gitignore's directory, "*" "?" "[...]" never match "/", "**" spans directories,
and a file can never be re-included if a parent directory is excluded.

Usage:
  python3 gitignore_check.py .gitignore [--test paths.txt] [--json]
  python3 gitignore_check.py --self-test
Paths file: one path per line, "+ path" = must be ignored, "- path" = must NOT be
ignored, end a path with "/" if it is a directory, "#" starts a comment.
Exit codes: 0 = no HIGH findings and all tests pass, 1 = HIGH finding or failed test,
2 = unreadable input.
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
SECRET_PROBES = [".env", "config/.env.production", "certs/server.key", "deploy/id_rsa",
                 "secrets/service-account.pem"]
POSIX_CLASSES = {"alnum": "a-zA-Z0-9", "alpha": "a-zA-Z", "digit": "0-9", "lower": "a-z",
                 "upper": "A-Z", "space": " \\t\\n\\r\\f\\v", "xdigit": "0-9A-Fa-f",
                 "punct": re.escape("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"), "blank": " \\t"}


class Rule:
    def __init__(self, line_no, raw, negate, dir_only, anchored, body, regex):
        self.line_no, self.raw, self.negate, self.dir_only = line_no, raw, negate, dir_only
        self.anchored, self.body, self.regex = anchored, body, regex

    def matches(self, rel_path, is_dir):
        if self.dir_only and not is_dir:
            return False
        target = rel_path if self.anchored else rel_path.rsplit("/", 1)[-1]
        return self.regex.fullmatch(target) is not None

    def label(self):
        return f"L{self.line_no} `{self.raw}`"


def strip_trailing_spaces(s):
    """git drops trailing spaces unless the last one is escaped with a backslash."""
    end = len(s)
    while end > 0 and s[end - 1] == " ":
        bs, k = 0, end - 2
        while k >= 0 and s[k] == "\\":
            bs += 1; k -= 1
        if bs % 2 == 1:
            break
        end -= 1
    return s[:end]


def class_to_regex(body):
    out, m = "", 0
    while m < len(body):
        ch = body[m]
        if ch == "[" and body.startswith("[:", m):
            end = body.find(":]", m + 2)
            if end != -1 and body[m + 2:end] in POSIX_CLASSES:
                out += POSIX_CLASSES[body[m + 2:end]]; m = end + 2; continue
        if ch == "\\" and m + 1 < len(body):
            out += re.escape(body[m + 1]); m += 2; continue
        if ch == "-" and 0 < m < len(body) - 1:
            out += "-"; m += 1; continue
        out += re.escape(ch); m += 1
    return out


def glob_to_regex(p):
    out, i, n = "", 0, len(p)
    while i < n:
        c = p[i]
        if c == "\\":
            if i + 1 < n:
                out += re.escape(p[i + 1]); i += 2
            else:
                i += 1  # a lone trailing backslash matches nothing extra
            continue
        if c == "*":
            j = i
            while j < n and p[j] == "*":
                j += 1
            if j - i >= 2 and (i == 0 or p[i - 1] == "/") and (j == n or p[j] == "/"):
                if j == n:
                    out += ".*"; i = j
                else:
                    out += "(?:.*/)?"; i = j + 1
                continue
            out += "[^/]*"; i = j; continue
        if c == "?":
            out += "[^/]"; i += 1; continue
        if c == "[":
            j = i + 1
            neg = j < n and p[j] in "!^"
            if neg:
                j += 1
            k = j + 1 if j < n and p[j] == "]" else j
            while k < n and p[k] != "]":
                if p[k] == "[" and p.startswith("[:", k):
                    end = p.find(":]", k + 2)
                    if end != -1:
                        k = end + 2; continue
                k += 2 if p[k] == "\\" else 1
            if k >= n:
                out += re.escape("["); i += 1; continue
            cls = class_to_regex(p[j:k])
            out += ("[^/" + cls + "]") if neg else ("(?!/)[" + cls + "]")
            i = k + 1; continue
        out += re.escape(c); i += 1
    return out


def parse_rules(text):
    rules, findings, seen = [], [], {}
    for no, raw in enumerate(text.split("\n"), 1):
        if raw.endswith("\r"):
            findings.append(("LOW", "CRLF", no, "line ends with CR (Windows line ending)",
                             "Save the file with LF line endings; set `* text=auto` in .gitattributes."))
            raw = raw[:-1]
        if not raw.strip() or raw.startswith("#"):
            continue
        line = strip_trailing_spaces(raw)
        if line != raw:
            findings.append(("LOW", "TRAILING_SPACE", no, f"`{raw}` has trailing spaces, which git drops",
                             "Delete them; escape a real trailing space as `\\ `."))
        if raw[0] in " \t":
            findings.append(("MEDIUM", "LEADING_SPACE", no, f"`{raw}` starts with whitespace, which git keeps as part of the name, so it matches nothing useful",
                             "Remove the leading whitespace."))
        m = re.search(r"\s#", line)
        if m:
            findings.append(("MEDIUM", "INLINE_COMMENT", no, f"`{line}`: git has no inline comments, so `{line[m.start():].strip()}` becomes part of the pattern",
                             "Move the comment to its own line starting with #."))
        if re.search(r"\\[A-Za-z0-9_]", line):
            findings.append(("MEDIUM", "BACKSLASH_SEPARATOR", no, f"`{line}` uses a backslash, which escapes the next character instead of separating directories",
                             "Use forward slashes: `" + line.replace("\\", "/") + "`."))
        key = line.strip()
        if key in seen:
            findings.append(("LOW", "DUPLICATE", no, f"`{key}` repeats line {seen[key]}", "Delete the duplicate."))
        else:
            seen[key] = no
        negate = line.startswith("!")
        body = line[1:] if negate else line
        if body.startswith("\\!") or body.startswith("\\#"):
            body = body[1:]
        if negate and not body:
            findings.append(("LOW", "EMPTY_NEGATION", no, "`!` alone does nothing", "Delete the line."))
            continue
        dir_only = body.endswith("/") and not body.endswith("\\/")
        if dir_only:
            body = body.rstrip("/")
        if not body:
            continue
        anchored = "/" in body
        if body.startswith("/"):
            body = body[1:]
        if any((m.start() > 0 and body[m.start() - 1] != "/") or (m.end() < len(body) and body[m.end()] != "/")
               for m in re.finditer(r"\*{2,}", body)):
            findings.append(("LOW", "DOUBLE_STAR", no, f"`{line}`: `**` not between slashes acts like a single `*`",
                             "Use `**/name`, `dir/**` or `a/**/b`, or a single `*`."))
        rules.append(Rule(no, line, negate, dir_only, anchored, body, re.compile(glob_to_regex(body), re.S)))
    return rules, findings


def last_match(rules, rel_path, is_dir):
    hit = None
    for r in rules:
        if r.matches(rel_path, is_dir):
            hit = r
    return hit


def check_path(rules, path):
    """Return (ignored, reason, blocked_rule) following git's parent-directory rule."""
    is_dir = path.endswith("/")
    parts = [p for p in path.strip("/").split("/") if p]
    for k in range(1, len(parts) + 1):
        sub = "/".join(parts[:k])
        sub_is_dir = k < len(parts) or is_dir
        hit = last_match(rules, sub, sub_is_dir)
        if k < len(parts):
            if hit and not hit.negate:
                own = last_match(rules, "/".join(parts), is_dir)
                blocked = own if own and own.negate else None
                return True, f"parent `{sub}/` excluded by {hit.label()}", blocked
        else:
            if hit is None:
                return False, "no rule matches", None
            if hit.negate:
                return False, f"re-included by {hit.label()}", None
            return True, f"ignored by {hit.label()}", None
    return False, "empty path", None


def literal_dirs(body):
    parts = body.split("/")[:-1]
    out = []
    for p in parts:
        if any(ch in p for ch in "*?[\\"):
            break
        out.append(p)
    return out


def lint_negations(rules):
    findings = []
    for idx, r in enumerate(rules):
        if not r.negate or not r.anchored:
            continue
        before = rules[:idx]
        dirs = literal_dirs(r.body)
        for k in range(1, len(dirs) + 1):
            sub = "/".join(dirs[:k])
            hit = last_match(before, sub, True)
            if hit and not hit.negate:
                findings.append(("HIGH", "NEGATION_BLOCKED", r.line_no,
                                 f"`{r.raw.strip()}` can never apply: its parent directory `{sub}/` is excluded by {hit.label()} and git does not look inside excluded directories",
                                 f"Exclude the contents instead of the directory (`{sub}/*` or `{sub}/**`), then keep the `!` rule; or re-include `!{sub}/` first."))
                break
    return findings


def lint_secrets(rules):
    findings = []
    missing = [p for p in SECRET_PROBES if not check_path(rules, p)[0]]
    if missing:
        findings.append(("MEDIUM", "SECRETS_NOT_COVERED", 0, "these typical secret files would NOT be ignored: " + ", ".join(missing),
                         "Add `.env`, `.env.*` (with `!.env.example`), `*.pem`, `*.key`, `id_rsa*` if the project could ever contain them."))
    if check_path(rules, ".env.example")[0]:
        findings.append(("LOW", "ENV_EXAMPLE_IGNORED", 0, "`.env.example` is ignored, so the documented template cannot be committed",
                         "Add `!.env.example` after the .env rule."))
    return findings


def explain(rule):
    body = rule.body
    kind = "directories" if rule.dir_only else "files or directories"
    verb = "Re-includes" if rule.negate else "Ignores"
    wild = any(ch in body for ch in "*?[")
    if rule.anchored and body.endswith("/**") and "*" not in body[:-3]:
        text = f"{verb} everything inside `{body[:-3]}/` (but not the folder itself), relative to the .gitignore folder"
    elif rule.anchored and body.startswith("**/") and "/" not in body[3:]:
        text = f"{verb} {kind} {'matching' if wild else 'named'} `{body[3:]}` at any depth"
    elif rule.anchored:
        text = f"{verb} {kind} {'matching' if wild else 'at'} `{body}` relative to the .gitignore folder only"
    else:
        text = f"{verb} {kind} {'matching' if wild else 'named'} `{body}` at any depth"
    if not rule.negate and not body.endswith("/**"):
        text += "; a matched directory hides everything inside it"
    if rule.negate:
        text += " (only works if no parent directory is excluded)"
    return text + "."


def read_tests(text):
    tests = []
    for no, line in enumerate(text.splitlines(), 1):
        s = line.strip()
        if not s or s.startswith("#"):
            continue
        if s[0] not in "+-" or len(s) < 3:
            raise ValueError(f"paths file line {no}: expected '+ path' or '- path', got {line!r}")
        tests.append((s[0] == "+", s[1:].strip()))
    return tests


def review(gi_text, tests_text=None):
    rules, findings = parse_rules(gi_text)
    findings += lint_negations(rules)
    findings += lint_secrets(rules)
    anchored = [r.line_no for r in rules if r.anchored and not r.negate and "/" in r.body
                and not r.raw.startswith(("/", "**/")) and not r.body.endswith("/**")]
    if anchored:
        findings.append(("INFO", "ANCHORED_BY_SLASH", anchored[0],
                         "lines " + ", ".join(map(str, anchored)) + " contain a slash, so they only match relative to the .gitignore folder, not deeper copies",
                         "Fine if intended; add a leading `/` to make that explicit, or prefix `**/` to match at any depth."))
    results = []
    if tests_text:
        for want, path in read_tests(tests_text):
            got, reason, blocked = check_path(rules, path)
            note = reason
            if blocked:
                note += f"; {blocked.label()} cannot re-include it"
            results.append({"path": path, "expect": "ignored" if want else "kept",
                            "actual": "ignored" if got else "kept", "pass": got == want, "why": note})
    findings.sort(key=lambda f: (SEV_ORDER[f[0]], f[2]))
    return {"rules": [{"line": r.line_no, "pattern": r.raw, "meaning": explain(r)} for r in rules],
            "findings": [dict(zip(("severity", "code", "line", "problem", "fix"), f)) for f in findings],
            "tests": results}


def render(name, rep):
    out = [f"# .gitignore review: {name}", ""]
    counts = {s: sum(1 for f in rep["findings"] if f["severity"] == s) for s in SEV_ORDER}
    failed = sum(1 for t in rep["tests"] if not t["pass"])
    out.append(f"Findings: {counts['HIGH']} HIGH, {counts['MEDIUM']} MEDIUM, {counts['LOW']} LOW, {counts['INFO']} INFO")
    if rep["tests"]:
        out.append(f"Tests: {len(rep['tests']) - failed}/{len(rep['tests'])} passed")
    out += ["", "## Rules", "", "| Line | Pattern | Meaning |", "|---|---|---|"]
    for r in rep["rules"]:
        out.append(f"| {r['line']} | `{r['pattern']}` | {r['meaning']} |")
    if rep["tests"]:
        out += ["", "## Path tests", "", "| Result | Path | Expected | Actual | Decided by |", "|---|---|---|---|---|"]
        for t in rep["tests"]:
            out.append(f"| {'PASS' if t['pass'] else 'FAIL'} | `{t['path']}` | {t['expect']} | {t['actual']} | {t['why']} |")
    out += ["", "## Findings", ""]
    if rep["findings"]:
        out += ["| Severity | Code | Line | Problem | Fix |", "|---|---|---|---|---|"]
        for f in rep["findings"]:
            out.append(f"| {f['severity']} | {f['code']} | {f['line'] or '-'} | {f['problem']} | {f['fix']} |")
    else:
        out.append("No findings.")
    out += ["", "Confirm on the real repo with `git check-ignore -v <path>` and `git status --ignored`; files that are already tracked stay tracked until `git rm --cached <path>`."]
    return "\n".join(out)


def self_test():
    cases = []
    def t(name, cond):
        cases.append((name, bool(cond)))
    R = lambda s: parse_rules(s)[0]
    ign = lambda s, p: check_path(R(s), p)[0]
    t("*.log matches at any depth", ign("*.log", "a/b/c.log") and ign("*.log", "x.log"))
    t("* does not cross /", not ign("/a*z", "ab/cz") and ign("/a*z", "abcz"))
    t("name without slash matches basename anywhere", ign("build", "src/build/x.js"))
    t("leading slash anchors", ign("/vendor", "vendor/a") and not ign("/vendor", "lib/vendor/a"))
    t("middle slash anchors", ign("doc/frotz", "doc/frotz") and not ign("doc/frotz", "a/doc/frotz"))
    t("trailing slash = directory only", ign("logs/", "logs/x") and not ign("logs/", "logs"))
    t("dir-only rule ignores a dir path", ign("logs/", "logs/"))
    t("**/ prefix matches any depth", ign("**/foo", "foo") and ign("**/foo", "a/b/foo"))
    t("**/foo/bar", ign("**/foo/bar", "x/foo/bar") and not ign("**/foo/bar", "x/foo/baz"))
    t("trailing /** matches contents only", ign("abc/**", "abc/x/y") and not ign("abc/**", "abc"))
    t("a/**/b zero or more dirs", ign("a/**/b", "a/b") and ign("a/**/b", "a/x/y/b") and not ign("a/**/b", "ab"))
    t("? matches one char not /", ign("/a?c", "abc") and not ign("/a?c", "a/c"))
    t("[abc] class", ign("file[12].txt", "file1.txt") and not ign("file[12].txt", "file3.txt"))
    t("[!x] negated class", ign("f[!0-9]", "fa") and not ign("f[!0-9]", "f5"))
    t("[[:digit:]] posix class", ign("v[[:digit:]]", "v7") and not ign("v[[:digit:]]", "vx"))
    t("last match wins with negation", not ign("*.txt\n!keep.txt", "keep.txt") and ign("*.txt\n!keep.txt", "a.txt"))
    t("order matters", ign("!keep.txt\n*.txt", "keep.txt"))
    t("parent dir excluded blocks negation", ign("logs/\n!logs/.gitkeep", "logs/.gitkeep"))
    t("dir/* allows negation", not ign("logs/*\n!logs/.gitkeep", "logs/.gitkeep"))
    t("dir/** allows negation", not ign("coverage/**\n!coverage/README.md", "coverage/README.md"))
    t("re-include dir then file", not ign("/a/*\n!/a/b/\n/a/b/*\n!/a/b/c", "a/b/c"))
    t("escaped # and !", ign("\\#notes", "#notes") and ign("\\!bang", "!bang"))
    t("comment lines skipped", not ign("# *.log", "a.log"))
    t("trailing spaces stripped", ign("foo   ", "foo"))
    t("escaped trailing space kept", ign("foo\\ ", "foo ") and not ign("foo\\ ", "foo"))
    t("leading space kept as part of name", not ign(" Thumbs.db", "Thumbs.db"))
    t("inline comment becomes pattern", not ign("secrets.yml # x", "secrets.yml"))
    t("backslash escapes, not a separator", not ign("src\\gen", "src/gen/a.ts"))
    def codes(s):
        return {f[1] for f in parse_rules(s)[1]} | {f[1] for f in lint_negations(parse_rules(s)[0])} | {f[1] for f in lint_secrets(parse_rules(s)[0])}
    t("NEGATION_BLOCKED found", "NEGATION_BLOCKED" in codes("logs/\n!logs/.gitkeep"))
    t("NEGATION_BLOCKED not for dir/*", "NEGATION_BLOCKED" not in codes("logs/*\n!logs/.gitkeep"))
    t("NEGATION_BLOCKED via dir/* deeper", "NEGATION_BLOCKED" in codes("a/*\n!a/b/c.txt"))
    t("INLINE_COMMENT found", "INLINE_COMMENT" in codes("x.yml # c"))
    t("LEADING_SPACE found", "LEADING_SPACE" in codes(" x"))
    t("BACKSLASH_SEPARATOR found", "BACKSLASH_SEPARATOR" in codes("a\\b"))
    t("DUPLICATE found", "DUPLICATE" in codes("*.log\n*.log"))
    t("DOUBLE_STAR found", "DOUBLE_STAR" in codes("foo**") and "DOUBLE_STAR" not in codes("**/foo\nfoo/**"))
    t("CRLF found", "CRLF" in codes("a\r\nb"))
    t("SECRETS_NOT_COVERED found", "SECRETS_NOT_COVERED" in codes("*.log"))
    t("secrets covered", "SECRETS_NOT_COVERED" not in codes(".env\n.env.*\n!.env.example\n*.key\n*.pem\nid_rsa*"))
    t("ENV_EXAMPLE_IGNORED found", "ENV_EXAMPLE_IGNORED" in codes(".env*"))
    acodes = lambda s: {f["code"] for f in review(s)["findings"]}
    t("ANCHORED_BY_SLASH info", "ANCHORED_BY_SLASH" in acodes("src/gen") and "ANCHORED_BY_SLASH" not in acodes("/src/gen\nlogs/**\n**/x/y"))
    rep = review("logs/\n!logs/.gitkeep\n", "+ logs/a.log\n- logs/.gitkeep\n")
    t("test table marks blocked negation FAIL", rep["tests"][0]["pass"] and not rep["tests"][1]["pass"] and "cannot re-include" in rep["tests"][1]["why"])
    try:
        read_tests("? bad")
        t("bad paths line rejected", False)
    except ValueError:
        t("bad paths line rejected", True)
    t("findings sorted HIGH first", review("x # c\nlogs/\n!logs/a\n")["findings"][0]["severity"] == "HIGH")
    passed = sum(ok for _, ok in cases)
    for name, ok in cases:
        print(("PASS " if ok else "FAIL ") + name)
    print(f"{passed}/{len(cases)} passed")
    return 0 if passed == len(cases) else 1


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("gitignore", nargs="?")
    ap.add_argument("--test", help="paths file with '+ path' (must be ignored) and '- path' (must be kept)")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.gitignore:
        ap.print_usage(sys.stderr); return 2
    try:
        gi = open(a.gitignore, encoding="utf-8", newline="").read()
        tests = open(a.test, encoding="utf-8").read() if a.test else None
        rep = review(gi, tests)
    except (OSError, UnicodeDecodeError, ValueError) as e:
        print(f"error: {e}", file=sys.stderr); return 2
    print(json.dumps(rep, indent=2) if a.json else render(a.gitignore, rep))
    bad = any(f["severity"] == "HIGH" for f in rep["findings"]) or any(not t["pass"] for t in rep["tests"])
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
P@community_library
0
Provence Lavender Farmhouse at Sunrise (Image-to-Video Animation)
Text

Source: github.com/f/prompts.chat · Contributor: f

Start from the step 2 image "Stone Farmhouse at the End of Lavender Rows in Provence at Sunrise" as the exact first frame, and animate it into a calm 6-second cinematic shot. Keep the stone farmhouse with its terracotta roof and pale blue shutters in the right third, the tall cypress beside it, the lone oak tree on the left, the converging lavender rows and central path, the low sun at the far left, the horizon line, and the composition exactly as in the image. A gentle breeze makes the lavender rows sway softly, all in the same direction. The camera makes a very slow, steady push-in along the central row toward the farmhouse. The sun's golden glow slowly brightens and thin morning haze drifts across the hills on the left. A couple of small birds fly across the sky from right to left above the house, a few bees drift over the lavender in the foreground, and the small white clouds drift slightly. Natural, realistic motion, no cuts, no people, no new buildings or objects, no text. Soft sound of a light breeze, distant birdsong, and buzzing bees.
P@community_library
0
Provence Lavender Farmhouse Scene Concept Brief Builder
Text

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 an old stone farmhouse at the end of long rows of purple lavender in Provence at sunrise. 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 (lavender rows swaying in a light breeze, morning haze drifting over the hills, the low sun's glow slowly brightening, small clouds drifting, bees over the flowers, a bird or two crossing the sky) while keeping the composition calm, readable, and easy to animate.

My starting choices:
- Farmhouse: a two-story old stone farmhouse with a terracotta tile roof and pale blue shutters
- Trees: one tall dark cypress right beside the house and a lone round oak tree on a grassy rise
- Lavender field: long straight rows of purple lavender with dirt paths between them, converging toward the house
- Landscape: gentle rolling hills fading to blue in the distance
- Time and light: sunrise with the sun just above the hills, warm golden light, long shadows, light haze
- Visual style: photoreal landscape photography, natural colors
- Mood words: peaceful, warm, fragrant, timeless

First ask me, in one short message, for anything missing: the camera position I prefer (low at the end of a lavender row, standing height on the path, or slightly elevated), which side of the frame the farmhouse and the oak tree should sit on, where the sun should be, how much haze I want, and anything to avoid (people, cars, tractors, fences, signs, birds that would have to fly convincingly, text, logos, or copying a specific real photograph).

Then produce the brief with these sections:

1. **Scene Overview**: two or three sentences describing the place, the time, the light, and the feeling of the moment. Describe an imagined Provence scene in the spirit of the region, not a copy of a specific photograph or a named farm.

2. **Farmhouse and Trees**: the farmhouse's position and size in the frame, its stone walls, roof, shutters, windows, and the shrubs around it, the cypress and oak trees and where they stand, and how the low sun lights each of them.

3. **Lavender Rows and Foreground**: the number of rows visible, the central path, how the rows converge toward the vanishing point, the color of the flowers in sun and shade, and the texture of the closest stems.

4. **Landscape and Depth**: the depth layers from the foreground lavender to the farmhouse, the oak on its rise, the middle-distance greenery, and the hills on the horizon, and how haze softens each layer.

5. **Light and Sky**: the sun's position and height, the glow and lens warmth around it, the sky gradient, the shape and position of the clouds, the length and direction of the shadows along the rows.

6. **Fixed Visual Details**: a numbered list of 8 to 10 details that must stay identical in the still image and in the video (farmhouse position, roof and shutter colors, cypress position, oak position, row direction and central path, sun position, horizon line, cloud layout, camera position, framing).

7. **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 lavender, the haze, the sun glow, the clouds, the trees, bees or birds, and the camera move. Keep total motion subtle and physically plausible, with one wind direction.

8. **Prompt Seed A - Hero Still Image**: a ready-to-paste image prompt under 170 words for a wide landscape 16:9 frame, with the lavender rows, the stone farmhouse, and the sunrise in the first sentence, ending with: no people, no text, no logos.

9. **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 lavender swaying, the haze drifting, the glow brightening, small clouds drifting, one very slow push-in along the central row for about 6 seconds, and says to keep the farmhouse, trees, rows, and framing exactly as in the image with no new objects.

Rules: no people, vehicles, or farm machinery; no text, signs, or logos; no fast camera moves or cuts; and no motion that breaks physics (all plants bend the same way in the wind, the house and trees never move or change shape, the sun does not jump, shadows stay consistent with the sun). Use short paragraphs and lists.
P@community_library
0
Photoreal Highland Cow in a Misty Scottish Glen at Sunrise
Text

Source: github.com/f/prompts.chat · Contributor: f

A photoreal wildlife photograph of a shaggy ginger Highland cow standing in tall golden grass in a misty Scottish glen at sunrise, in a wide landscape 16:9 frame. The cow stands in the left half of the frame facing the camera, its long rust-orange and copper coat thick and wavy, with tiny dew drops caught in the hair. A heavy fringe of hair hangs over its eyes, its broad pink-brown nose and muzzle are sharp and detailed, and a pair of long, pale, upswept horns curve outward and up from its head. A cloud of warm breath and mist drifts to the right of its head in the cold morning air. In the foreground, tall dry golden grass and dewy seed heads are softly out of focus. Soft golden backlight from a low sun in the upper right makes the edges of the coat glow and fills the glen with warm haze. In the background, blurred rolling hills in muted brown, mauve, and purple tones fade into the mist, with patches of pink heather and a pale silver loch in the right middle distance. Shallow depth of field, 200mm telephoto compression, natural colors, calm, quiet mood. No people, no fences, no text, no watermark.
P@community_library
0
Stone Farmhouse at the End of Lavender Rows in Provence at Sunrise
Text

Source: github.com/f/prompts.chat · Contributor: f

Long straight rows of purple lavender lead toward an old two-story stone farmhouse in Provence at sunrise, seen from a low viewpoint at the end of a central dirt path, in a wide landscape 16:9 frame. The rounded lavender rows fill the whole lower half of the image and converge toward the middle distance, with narrow brown dirt paths between them and one path running straight up the center. The farmhouse stands in the right third: rough beige stone walls, a brown-terracotta tile roof, and small windows with pale blue shutters, with green shrubs in front of it. A single tall, dark green cypress tree rises right beside the house on its right side. In the left middle distance, a lone round green oak tree stands on a gentle grassy rise. The low sun sits just above the hills at the far left, glowing gold with a soft lens glow and light morning haze, casting warm light across the tops of the lavender. Rolling blue hills stretch across the horizon, and a few small white clouds float in a pale blue sky that warms to peach near the sun. Photoreal landscape photography, wide angle, natural colors, peaceful mood. No people, no text, no logos.
P@community_library
0
Stained Glass Window of a Great Horned Owl Under a Full Moon
Text

Source: github.com/f/prompts.chat · Contributor: f

A stained glass window panel of a great horned owl perched on an oak branch under a full moon, in a wide landscape 16:9 rectangular composition. The owl sits in the center of the panel facing the viewer, with tall pointed ear tufts, large glowing amber-orange eyes, a white throat patch, and mottled brown, tan, and black feather glass with fine painted detail. It grips a thick brown oak branch that runs across the lower third of the panel from left to right, with dark talons wrapped around it. Behind the owl, a large pale yellow full moon with soft crater markings sits slightly to the upper left of center. The night sky is built from irregular pieces of textured cobalt blue and deep violet glass with tiny bubbles, scattered with about a dozen small gold glass stars. Clusters of lobed oak leaves in emerald, lime, and olive green glass spread from the branch on both the left and right sides. Thick dark lead lines separate every glass piece, light shines through so the colors glow like a real window, and the whole panel is framed by a border of square ruby red textured glass tiles. Rich jewel tones, symmetrical, decorative, calm night mood. No text, no signature.
P@community_library
0
Ukiyo-e Woodblock Print of a River Ferry Crossing in Autumn Rain
Text

Source: github.com/f/prompts.chat · Contributor: f

A Japanese ukiyo-e woodblock print of a small wooden ferry boat crossing a wide, calm river in autumn rain, in a wide landscape 16:9 composition on warm beige paper with visible fibers and wood grain texture. A long, low wooden boat sits in the center right of the frame, carrying three seated passengers under round oiled paper umbrellas, from left to right one vermilion red, one deep indigo blue, and one ochre yellow, the passengers wearing simple robes in indigo, ochre, and rust. At the stern on the right, a ferryman stands in a pale blue-grey robe and a wide conical straw hat, pushing a long wooden pole diagonally into the water. In the upper left, a red maple tree reaches into the frame with clusters of bright orange-red leaves, above a grassy, rocky near bank with tall reeds in the lower left. In the background, a misty grey-blue volcano-shaped mountain rises behind a hazy far shore with soft tree silhouettes on the right. Fine diagonal rain lines fall across the whole scene, the river shows gentle horizontal ripple lines, and the sky has a soft bokashi gradient from dark slate grey at the top to pale beige near the horizon. Flat color areas, fine black outlines, muted indigo, ochre, vermilion, and grey palette, quiet and contemplative mood. No text, no seals, no signature, no border.
P@community_library
0
Study Abroad Departure Countdown Plan (YAML)
Structured

Source: github.com/f/prompts.chat · Contributor: f

role: Organized, encouraging study abroad advisor who helps students turn the chaotic months before leaving for a semester or degree in another country into a calm, week-by-week countdown so nothing important is missed and arrival week goes smoothly
task: >
  Turn my destination, program, dates, and situation into a complete departure plan as YAML:
  a document and deadline check, a week-by-week countdown from 10 weeks before departure to the
  day I fly, money and banking setup, health and insurance steps, housing and arrival logistics,
  a phone and digital setup, a packing plan by category with weight limits, things to close or
  pause at home, a departure day timeline, and a first-week-after-arrival checklist.

inputs:
  home_country: Canada
  destination: Lyon, France
  program: one-semester university exchange, classes taught in English and French
  departure_date: late August
  program_start_date: first week of September
  length_of_stay: 5 months
  visa_status: not started, I think I need a long-stay student visa
  housing: university residence offered, not yet confirmed
  budget: about 1100 per month plus flights, from savings and a small scholarship
  health_needs: one daily prescription medication, wear glasses
  language_level: intermediate French
  luggage: one checked bag 23 kg and one carry-on
  things_at_home: a phone contract, a part-time job, a car I share with my parents
  first_time_abroad_alone: yes

ask_first:
  - The exact departure date, program start date, and orientation date, and whether you have booked a flight yet
  - Your passport expiry date and whether the destination requires a student visa or residence permit, with the official consulate or embassy page you are using
  - Whether your university or the host school gives a pre-departure checklist, a housing deadline, or required health insurance
  - Any prescriptions, allergies, or medical devices you need to bring, and whether your current insurance covers you abroad
  - Your budget sources and whether you already have a card that works abroad without high foreign transaction fees

rules:
  - Never invent visa rules, fees, processing times, or official requirements. Write them as tasks to confirm on the official government, consulate, or university website, and flag them with confirm_officially true.
  - Put long lead-time items first. Passport renewal, visa appointments, and housing confirmation can take weeks; if the timeline is too short for any of them, say so clearly in risks and suggest what to do now.
  - Build the countdown backward from the departure date, one block per week from week 10 to week 1, then the last 3 days and the departure day hour by hour.
  - For money, cover how to access funds on arrival (a little local cash, two cards kept in different places), how to tell my bank I am traveling, and a monthly budget split by category based on my numbers. Label all costs as estimates.
  - For health, cover enough prescription medication or a plan to refill abroad with a doctor's letter and the original packaging, copies of prescriptions in generic names, vaccinations to check, and insurance proof. Do not give medical advice beyond telling me to confirm with my doctor and pharmacist.
  - For packing, plan by category within my luggage limits, mark what to buy on arrival instead, and keep documents, medication, chargers, and one change of clothes in the carry-on.
  - Include safety basics: emergency numbers at the destination, registering with my country's travel registration service if available, sharing my itinerary with family, and storing digital and paper copies of key documents.
  - Keep the tone practical and reassuring. Output only valid YAML matching output_format. No prose outside the YAML.

output_format:
  summary:
    destination: string
    departure_date: string
    weeks_until_departure: number
    readiness: on_track | tight | at_risk
    top_risks:
      - string
    assumptions:
      - string
    open_questions:
      - string
  documents:
    - item: string
      status: have | in_progress | missing | check
      deadline: string
      lead_time: string
      confirm_officially: boolean
      notes: string
  countdown:
    - week: string
      focus: string
      tasks:
        - task: string
          category: documents | money | health | housing | travel | packing | home | academic | digital
          done_when: string
  money:
    monthly_budget:
      - category: string
        estimated_amount: number
    banking_setup:
      - string
    arrival_cash_plan: string
    backup_access_plan: string
  health_and_insurance:
    insurance_steps:
      - string
    medication_plan:
      - string
    appointments_before_leaving:
      - string
  housing_and_arrival:
    housing_steps:
      - string
    airport_to_housing_plan: string
    first_night_backup: string
  phone_and_digital:
    - string
  packing:
    carry_on:
      - string
    checked_bag:
      - category: string
        items:
          - string
    buy_on_arrival:
      - string
    leave_at_home:
      - string
    weight_check: string
  close_or_pause_at_home:
    - item: string
      action: string
      when: string
  departure_day_timeline:
    - time: string
      action: string
  first_week_after_arrival:
    - day: string
      tasks:
        - string
  safety:
    emergency_numbers_to_save:
      - string
    registrations:
      - string
    document_copies_plan: string
  final_checks_night_before:
    - string
P@community_library
0
Home Inspection Report Repair and Negotiation Plan (JSON)
Structured

Source: github.com/f/prompts.chat · Contributor: f

{
  "role": "Practical, calm home buying advisor who helps first-time and repeat buyers turn a long, alarming home inspection report into a clear, prioritized list of what matters, what it might cost, and what to ask the seller for, without exaggerating minor issues or giving legal advice",
  "task": "Turn my inspection findings, the property details, and my deal terms into a single strict JSON plan: an overall verdict, every finding sorted by safety, cost, and urgency, a rough cost range for each with the reasoning, which items need a specialist follow-up before the deadline, a ranked list of repair requests or credits to ask the seller for, questions for the seller and inspector, a negotiation strategy with a walk-away line, a timeline to the inspection contingency deadline, and a first-year maintenance list. Never invent findings, measurements, prices, or local rules that I did not give.",
  "context": {
    "property": "a 1978 two-story detached house, 160 square meters, wood frame, basement",
    "location_and_climate": "a suburb with cold snowy winters and humid summers",
    "purchase_price": "420000",
    "currency": "USD",
    "inspection_findings": "roof shingles near end of life with some curling; furnace 22 years old, works; water heater 14 years old; double-tapped breakers in the electrical panel; a few ungrounded outlets; minor efflorescence and one hairline crack in the basement wall; slow drain in the upstairs bathroom; missing handrail on basement stairs; GFCI missing in the kitchen; gutters clogged; attic insulation thin",
    "inspector_notes": "recommends an electrician and a roofer evaluate; no active leaks seen",
    "market_conditions": "balanced market, house has been listed for 30 days",
    "contingency_deadline": "7 days from today",
    "my_budget_after_closing": "about 15000 for repairs in the first year",
    "my_skills": "can paint and do small fixes, no electrical or roofing work",
    "priorities": "safety first, then avoiding big surprise costs in the next five years"
  },
  "ask_first": [
    "The full text or a list of every item in the inspection report summary, including any items marked as safety hazards or for further evaluation by a specialist",
    "The age of the roof, furnace or boiler, air conditioner, water heater, and electrical panel if known from the listing or the seller disclosure",
    "Whether a sewer scope, radon test, mold test, pest or termite inspection, or chimney inspection was done or is common in my area",
    "Your exact contingency deadline, whether your contract allows repair requests, credits, a price reduction, or only accept or cancel, and whether your agent or attorney has given advice",
    "How long you plan to live in the home and whether you would rather have the seller fix items or receive a credit to fix them yourself"
  ],
  "rules": [
    "Use only my inputs for findings and property facts. If something is missing, write 'unknown' in assumptions, add a question to open_questions, and never guess the condition of something the report did not cover.",
    "Sort every finding into one category: safety_hazard, major_system (roof, structure, foundation, electrical, plumbing, heating and cooling, water intrusion), repair_soon, maintenance, or cosmetic. Treat anything that could cause fire, shock, carbon monoxide, falls, or water damage as safety_hazard or major_system.",
    "Give each finding a rough cost range as low and high numbers in my currency with the basis for the estimate (typical scope, whether it needs a licensed trade). Label every cost as a rough estimate to be confirmed with local quotes, and never present it as a quote.",
    "Mark findings that need a specialist evaluation before the contingency deadline (for example an electrician for double-tapped breakers, a roofer for remaining roof life, a structural engineer only if cracks are wide, stepped, or growing) and schedule those first.",
    "Explain normal aging versus real defects in plain language, so I do not panic over old but working systems or minor items. Age alone is not a defect, but it is a budgeting fact.",
    "Rank seller requests by impact: safety hazards and major systems first, then items with high cost or uncertainty. Do not ask the seller for cosmetic or routine maintenance items unless the market strongly favors buyers. Suggest a credit instead of a repair when quality control matters to me.",
    "Give a negotiation strategy that fits the market conditions, with an opening ask, a reasonable target, and a walk-away condition based on safety and total expected cost. Keep the tone respectful and factual.",
    "Do not give legal advice. Remind me to confirm contract terms, deadlines, and wording with my agent or attorney, and say that local disclosure and permit rules vary.",
    "Output only valid JSON matching output_format. No markdown, no comments, no prose outside the JSON."
  ],
  "output_format": {
    "summary": {
      "property": "string",
      "overall_verdict": "proceed | proceed_with_requests | get_more_information | consider_walking_away",
      "verdict_reasons": ["string"],
      "total_estimated_cost_low": "number",
      "total_estimated_cost_high": "number",
      "currency": "string",
      "assumptions": ["string"],
      "open_questions": ["string"]
    },
    "findings": [
      {
        "item": "string",
        "location": "string",
        "category": "safety_hazard | major_system | repair_soon | maintenance | cosmetic",
        "plain_explanation": "string",
        "normal_aging_or_defect": "normal_aging | defect | unclear",
        "urgency": "before_closing | first_3_months | first_year | when_convenient",
        "cost_low": "number",
        "cost_high": "number",
        "cost_basis": "string",
        "needs_licensed_trade": "boolean",
        "diy_possible": "boolean"
      }
    ],
    "specialist_followups": [
      {
        "specialist": "string",
        "findings_covered": ["string"],
        "why": "string",
        "book_by": "string",
        "what_to_ask_them": ["string"]
      }
    ],
    "seller_requests": [
      {
        "rank": "number",
        "item": "string",
        "ask_type": "repair_by_licensed_pro | credit_at_closing | price_reduction | further_evaluation",
        "amount_or_scope": "string",
        "justification": "string",
        "can_drop_if_needed": "boolean"
      }
    ],
    "questions_for_seller": ["string"],
    "questions_for_inspector": ["string"],
    "negotiation_strategy": {
      "market_read": "string",
      "opening_ask": "string",
      "target_outcome": "string",
      "walk_away_conditions": ["string"],
      "tone_and_talking_points": ["string"],
      "documents_to_attach": ["string"]
    },
    "timeline_to_deadline": [
      {
        "day": "string",
        "tasks": ["string"],
        "owner": "me | agent | attorney | specialist"
      }
    ],
    "five_year_budget_outlook": [
      {
        "system": "string",
        "current_age_or_condition": "string",
        "expected_action": "string",
        "estimated_year": "string",
        "cost_low": "number",
        "cost_high": "number"
      }
    ],
    "first_year_maintenance": [
      {
        "task": "string",
        "season": "string",
        "diy_or_pro": "diy | pro"
      }
    ],
    "move_in_safety_checklist": ["string"],
    "disclaimers": ["string"]
  }
}
P@community_library
0
Act as a Home Aquarium Health Troubleshooter
Text

Source: github.com/f/prompts.chat · Contributor: f

Act as a Home Aquarium Health Troubleshooter. You help hobbyists figure out why their freshwater tank or its fish are struggling and guide them back to a stable, healthy aquarium. You think like an experienced fish keeper and a careful vet: you start with water quality before blaming disease, you ask for test numbers instead of guessing, you explain every term the first time you use it, and you always separate what is likely from what is only possible. You never recommend dosing medication blindly, and you say clearly when a problem needs a specialist aquatic vet or a local fish store with proper testing equipment.

My tank:
- Tank size and age: about 100 liters, set up 7 weeks ago
- Fish and other animals: 6 neon tetras, 2 platies, 1 bristlenose pleco, a few nerite snails
- What I am seeing: cloudy white water, two tetras gasping near the surface, one platy with clamped fins
- When it started: 4 days ago, right after I added the platies
- Water test results (if any): not tested yet, I have strips but no liquid kit
- Filter, heater, and lighting: hang-on-back filter, 26 C heater, LED light on 10 hours a day
- Maintenance routine: 25 percent water change every two weeks with dechlorinator, filter sponge rinsed under the tap
- Feeding: flakes twice a day, algae wafer every other night
- Plants and decor: a few live plants, driftwood, gravel
- Recent changes: new fish, moved the tank closer to a window

First, in one short message, ask me for anything important that is missing: exact test numbers for ammonia, nitrite, nitrate, pH, and temperature (and GH or KH if I have them), whether any fish have died and how quickly, close-up descriptions or photos of any spots, fuzz, wounds, or swollen bellies, whether the new fish were quarantined, and what the water from my tap tests at. If anything looks like an emergency (high ammonia or nitrite, fish gasping, heater failure), give me the emergency steps immediately and ask questions after.

Then guide me through these sections:

1. **Triage Summary**: a short verdict (stable, watch closely, act today, emergency) with the two or three most likely causes in order, and the one thing I should do in the next hour.

2. **Aquarium Words You Will Meet**: a short glossary with term and plain meaning: nitrogen cycle, beneficial bacteria, ammonia, nitrite, nitrate, new tank syndrome, cycling, bioload, dechlorinator, GH, KH, pH swing, quarantine tank, and bacterial bloom.

3. **Symptom Decoder**: a table of every symptom I described, with columns: symptom, what it usually means, the more serious possibility, what test or observation tells them apart, and how urgent it is. Cover water appearance (cloudy white, green, brown, yellow, foamy, smelly) and fish behavior (gasping, clamped fins, hiding, flashing or rubbing, lethargy, not eating, spots, fuzz, frayed fins, bloating).

4. **Water Quality Check**: what each test result means, the safe ranges for my fish, how to read strips versus liquid kits, why tap water and tank water should both be tested, and a clear rule for what to do at each level (for example any ammonia or nitrite above zero in an established tank means a water change today).

5. **Cycle Status**: whether my tank is likely fully cycled based on its age, stocking, and test results, how new fish and filter rinsing in tap water can crash the beneficial bacteria, and how to protect or rebuild the cycle without losing fish.

6. **Action Plan for the Next 7 Days**: a day-by-day table with day, actions, tests to run, what to watch for, and what result means it is working. Include safe water change sizes and temperature matching, feeding changes, filter care, and when to stop changing things.

7. **Disease or Water Problem?**: how to tell an environmental problem from an infection or parasite, the common illnesses that match my symptoms (for example ich, fin rot, columnaris, or velvet), and when medication makes sense. Never name a medication and dose without telling me to follow the product label exactly, remove carbon from the filter if the label says so, and check that the treatment is safe for snails, shrimp, plants, and scaleless fish like my pleco.

8. **Stocking and Compatibility Check**: whether my fish suit the tank size, temperature, and pH together, schooling numbers, adult sizes, bioload, and any likely bullying or fin nipping. Suggest changes only if they help the fish.

9. **Light, Algae, and Plants**: how the window move and the lighting hours affect algae and temperature, a sensible light schedule, and simple ways to keep plants healthy without overdosing fertilizer.

10. **Routine Care Schedule**: a weekly, monthly, and quarterly checklist with tasks, time needed, and why each matters: water changes, gravel vacuuming, rinsing the filter media in old tank water, testing, glass cleaning, checking the heater and thermometer, trimming plants, and a quarantine routine for new fish.

11. **Prevention Rules**: eight short rules that would have prevented today's problem, matched to what I told you.

12. **When to Get Help**: clear signs that need an aquatic vet or an experienced fish store, and what information and water sample to bring.

Rules: ask for numbers instead of assuming them; label every cause as likely, possible, or unlikely; prefer gentle, gradual fixes over big sudden changes; never suggest releasing fish or plants into the wild; keep animal welfare first; use plain language, short paragraphs, and tables where they help. End with the single most important thing I should do today.
P@community_library
0
IPO analysis
Text

Source: github.com/f/prompts.chat · Contributor: pvmi4i@gmail.com

Act as an investment analyst who have years of experience in valuing stocks. Who considers all the risks and assess the upside and future opportunities. Now prepare your listing day strategy for Jio platform if you have say 600 shares.
Use all the information available on internet but don't form conclusion on the basis of that only. Apply your intelligence as well as drhp info
P@community_library
0
Previous11 / 122Next