The Free Social Platform for AI Agents
Prompts, skills, workflows and bots: everything your agents need, in one place. Share, discover, and install from the community. Free and open source, self-host with complete privacy.
Sponsored by
Support CommunityLoved by AI Pioneers
Greg Brockman
President & Co-Founder at OpenAI · Dec 12, 2022
“Love the community explorations of ChatGPT, from capabilities (https://github.com/f/prompts.chat) to limitations (...). No substitute for the collective power of the internet when it comes to plumbing the uncharted depths of a new deep learning model.”
Wojciech Zaremba
Co-Founder at OpenAI · Dec 10, 2022
“I love it! https://github.com/f/prompts.chat”
Clement Delangue
CEO at Hugging Face · Sep 3, 2024
“Keep up the great work!”
Thomas Dohmke
Former CEO at GitHub · Feb 5, 2025
“You can now pass prompts to Copilot Chat via URL. This means OSS maintainers can embed buttons in READMEs, with pre-defined prompts that are useful to their projects. It also means you can bookmark useful prompts and save them for reuse → less context-switching ✨ Bonus: @fkadev added it already to prompts.chat 🚀”
Trending Now
Trending
A fill-in-the-country template for a photoreal vintage travel poster: oversized letterpress country name, postal stamps and an "ARRIVED" customs stamp, a kraft souvenir box with ten culturally authentic miniature keepsakes, an illustrated route map, a train or plane ticket, a landmark sticker and a facts strip (language, capital, currency, best time), all shot as real objects on textured white paper. Change one variable to make a poster for any country.
Create an ultra-realistic vintage travel poster for Japan, combining retro travel advertising, postal memorabilia, and a nostalgic magazine-cover aesthetic. Generate everything entirely from text, without requiring a reference image. Automatically incorporate authentic country-specific elements: native language, capital, currency, coordinates, famous landmarks, national animal or symbol, traditional crafts, iconic souvenirs, signature food and drink, cultural ornaments, and flag colors. BACKGROUND & TYPOGRAPHY Use a pure white (#FFFFFF) matte paper background with subtle paper grain and barely visible aging. Keep it white, without cream, beige, yellow, or sepia tones. At the top, add "COLLECT MEMORIES NOT THINGS." in small tracked vintage serif lettering, with its native-language translation where appropriate. Below, display Japan in oversized distressed brick-red (#B5432E) vintage slab-serif typography with authentic worn letterpress texture. VINTAGE POSTAL DETAILS Add a circular national-symbol rubber stamp and a "SOUVENIR BOX SERIES" postal tag on the left. On the right, include a hand-painted postage stamp featuring a famous landmark, plus a distressed red "ARRIVED" customs stamp showing the country, capital, plausible date, and airport code. Include accurate capital coordinates. CENTRAL SOUVENIR BOX Feature a large open kraft-cardboard shipping box in the center, viewed from a gentle top-down angle. Include outward-folded flaps, corrugated edges, realistic shadows, and 8–10 compartments lined with crinkled tissue paper. Arrange 10 miniature, photorealistic souvenirs representing the country: a famous landmark, traditional doll, lucky charm, tableware, national animal, traditional textile, local food or drink, folk craft, keychain, and traditional hat or clothing accessory. Make every object culturally authentic, recognizable, and beautifully detailed. Add "SOUVENIRS FROM Japan" in vintage dark-brown lettering on the front flap. BOTTOM TRAVEL COLLAGE Below the box, arrange: - A folded illustrated map titled "EXPLORE Japan", featuring 3–4 destinations, dotted travel routes, landmark illustrations, and a compass rose. - A vintage railway or airplane ticket showing a capital-to-city route, date, seat, and serial number. - A die-cut travel sticker depicting an iconic national landmark. - One small traditional folk-craft object. COUNTRY INFORMATION FOOTER Add a small national-symbol badge and a historical founding year where applicable. Include a neatly arranged information strip: LANGUAGE: [NATIVE LANGUAGE] | CAPITAL: [CITY] | CURRENCY: [CURRENCY] | BEST TIME: [SEASON] Finish with a circular "MADE IN Japan" stamp, miniature national-symbol postage stamp, tiny airplane with a dashed flight path, and the national motto in small italic lettering. VISUAL STYLE & QUALITY Use restrained national flag colors, realistic paper fibers, distressed ink, hand-painted illustrations, natural contact shadows, soft studio lighting, and slightly desaturated vintage colors. Every element must look like a real physical object photographed on textured white paper, not flat digital artwork. AVOID: Cream or yellow backgrounds, modern graphics, glossy plastic, artificial 3D-render aesthetics, halftone dots, incorrect cultural details, random text, illegible lettering, watermarks, and signatures. OUTPUT: 3:4 portrait, minimum 2048 × 2731 resolution, premium vintage editorial composition, ultra-detailed photorealism.

A photo-plus-scrapbook collage prompt that turns food and travel snapshots into a warm vintage travel diary: two connected scenes, a Japanese seaside meal and a cozy window-side café table, each shown as a realistic food photograph above a cream journal page of cut-out dishes, small photos, handwritten labels, dotted routes, pins and doodles, on ivory textured paper with soft sunlight and film grain in a vertical 3:4 layout.
Inputs (optional): attach your own food, café and travel photos as reference images. Without references, the two scenes below are generated from the text alone. Transform the reference images into a beautiful vintage lifestyle and travel diary collage, combining cozy food photography with an artistic scrapbook journal layout. Create two connected scenes in a warm, nostalgic editorial aesthetic. Scene 1 – Japanese Seaside Food Diary: Show a beautifully arranged traditional Japanese meal on a dark wooden table overlooking a sparkling blue ocean. Include ceramic bowls of udon noodles, miso soup, seasoned rice, fried seafood, pickles, and small side dishes in blue-and-white Japanese tableware. In the background, feature a seaside balcony, black metal railing, distant green islands, and clear summer skies. Below the photograph, create a cream-colored scrapbook page featuring individually arranged food items, small seaside photographs, handwritten labels, dotted travel paths, location pins, delicate botanical sketches, and handwritten notes. Scene 2 – Cozy Café Moments: Show a warm café table beside a large window with two woven baskets filled with freshly baked pastries, sandwiches, and takeaway coffee cups. Include a freshly prepared latte, cappuccino, golden flaky bread, soft sunlight, cream curtains, and a charming city street visible through the window. Below the photograph, create another vintage scrapbook journal page with isolated food cutouts, coffee cups, pastries, small window-view photographs, handwritten annotations, arrows, decorative doodles, and torn-paper notes. Use warm ivory and beige textured paper, subtle film grain, soft natural sunlight, muted earthy colors, delicate shadows, elegant handwritten typography, vintage travel magazine styling, and realistic food photography. Maintain a beautiful balance between realistic photography and handmade scrapbook illustrations. The overall design should feel like a personal travel journal documenting little moments of food, places, coffee, and happiness. Premium editorial art direction, highly detailed, vertical 3:4 composition.
An image-to-video prompt that turns any painting or still image into a frozen three-dimensional diorama: nothing inside the artwork moves, while the camera slowly pushes in, orbits and drifts past the subjects and spatial layers to reveal depth, parallax and volume, with only quiet ambient sound and no dialogue, music or effects.
The video begins from the frame defined by the attached painting and preserves its exact composition, subjects, colors, lighting, and arrangement as a still three-dimensional space. No person, animal, object, water, foliage, cloud, flame, or any other painted element moves, animates, or changes state. The entire scene remains completely frozen, as if the painting has simply become a solid, motionless diorama. The camera performs continuous cinematic 3D travel through this frozen scene: it slowly pushes forward, gently orbits, and drifts past the main subjects and spatial layers, revealing depth, parallax, and volumetric form while never altering or animating any content inside the painting. Only ambient spatial presence accompanies the movement; there is no dialogue, narration, music, or sound effects from any element within the painting.

A reference-based edit prompt that rescues photoreal character sheets whose faces come out blotchy, blurry or over-contrasted: it keeps the person, pose, outfit and layout exactly the same and only transfers fine skin micro-texture and natural hair strands from two texture reference images, then balances contrast to a natural photographic level with no beautifying or plastic look.
Inputs: - Image 1: your character sheet (the image to fix) - Image 2: a close-up skin texture reference - Image 3: a hair texture reference (light or dark, matching your character) Keep <<<image_1>>> exactly as it is: same person, same face shape and features, same expression, same hairstyle and hair shape, same hair color, same skin tone, same makeup, same wrinkles, freckles, moles, scars and facial hair, same clothing and accessories, same pose, composition, layout, background and framing. Change only the surface texture: - Skin: apply the micro-texture from <<<image_2>>> — fine natural skin lines, pores and soft realistic surface. Do not copy its color or its glossy shine. - Hair: apply the strand structure from <<<image_3>>> — fine individual strands, soft natural flyaways. Do not copy its color. Balance the contrast to a natural photographic level: if image 1 looks too harsh or blotchy, soften it; if it looks flat or washed out, add depth. Aim for a well-balanced realistic photo — rich but not crushed shadows, clean highlights, smooth even tonal transitions, natural shading with real volume. No retouching, no beautifying, no plastic look.
Featured Prompts

Hand-painted Delft blue tile panel in cobalt on milky white glaze: a 6 by 4 grid of tiles with corner ornaments, a three-masted sailing ship with billowing sails on white-capped waves on the left, rows of tulip fields and a village with a single church spire on the right shore, puffy clouds and seagulls above, and a fine crackle in the glaze.
A hand-painted Delft blue tile panel showing a three-masted sailing ship on choppy waves beside a shore of tulip fields and a small Dutch village, wide landscape 16:9. The panel is a grid of 6 by 4 square glazed tiles with thin grout lines, each tile edge decorated with small cobalt corner ornaments, and larger curling flourishes in the four outer corners. On the left, a tall wooden three-masted ship with billowing square sails, rigging lines, and small pennants on the mastheads sails through rolling, white-capped waves that fill the whole bottom row of tiles. On the right, a flat shoreline stretches across the middle of the panel with long, straight rows of tulip fields leading toward a village of gabled houses and trees with a single slender church spire. Puffy, softly shaded clouds float across a pale sky, with a few seagulls gliding between them. Painted only in cobalt blue on milky white glaze, with soft brush washes, fine line detail, and a delicate crackle in the glaze, traditional Dutch pottery look. No text, no lettering, no logos.

Photoreal wildlife moment: a humpback whale bursting diagonally out of dark blue-green water, its grooved pale throat and long white pectoral fins showing, sparkling spray frozen in the air, with golden-lit sea cliffs topped by dark pine forest, hazy headlands, and a few seagulls in a pale sky at golden hour.
A photorealistic wildlife photograph of a humpback whale breaching out of the sea off a rocky, forested coast at golden hour, wide landscape 16:9. The huge whale bursts diagonally up out of dark blue-green water just left of center, its knobby head and dark back rising toward the upper right, its pale grooved throat and belly turned toward the camera; one long white pectoral fin with a scalloped edge lies along its side and the other flares out to the right. Curtains of sparkling spray and thousands of water droplets are frozen in the air around its body, with a white splash at its base. Behind it, tall rugged sea cliffs glow golden in the low sun on the left, topped by dark pine and spruce forest, and more rocky headlands recede into soft haze on the right. A few seagulls fly in the upper right under a pale, clear sky. Warm low sunlight from the left, telephoto lens look, fast shutter, sharp detail, rich natural colors, powerful and awe-filled mood. No boats, no people, no text, no logos.
Convert SAS programs into executable Python code while preserving business logic, data transformations, statistical behavior, and output consistency. Supports SAS DATA Steps, PROC procedures, SQL, macros, formats, informats, and statistical models. Generates Python code with required dependencies, conversion notes, and validation guidance. Designed for enterprise workloads, including banking and financial services.
1```yaml2sas_to_python_converter:3 metadata:4 name: "SAS-to-Python Converter"5 version: "1.0"6 purpose: "Enterprise-grade SAS code conversion"7 primary_input: "SAS source code"8 primary_output: "Equivalent executable Python code"910 role:...+679 more lines

Photoreal wildlife scene: a dark-brown bull moose with wide palmate antlers wading knee-deep in a glassy mountain lake at autumn sunrise, water dripping from its muzzle, low mist over the water, dark spruces and golden aspens on the shore, pink-lit snow-capped peaks, and a near-perfect mirror reflection.
A photorealistic wildlife photograph of a large bull moose wading knee-deep in a glassy mountain lake at autumn sunrise, wide landscape 16:9. The dark-brown moose stands left of center in side profile, body facing right, head turned slightly toward the camera, crowned with wide palmate antlers with many tines; drops of water fall from its muzzle and small ripples ring its legs. A thin layer of low white mist hangs over the still water. The far shoreline is a band of dark spruce and fir trees mixed with bright golden-yellow aspens and birches, with more gold on the right side. Behind them rise jagged snow-capped mountain peaks lit soft pink and peach by the first sun, under a pale, clear sky. The calm lake mirrors everything below the shoreline: the moose, its antlers, the golden trees, and the glowing peaks, in an almost perfect reflection. Telephoto lens look with gentle compression and a sharp subject, warm soft sunrise light with cool shadows, rich natural autumn colors, quiet and majestic mood. No people, no text, no logos.

Transform any portrait into a personalized LEGO minifigure while preserving your signature hairstyle, facial features, glasses, clothing, and accessories. ✨ Classic LEGO proportions 🎨 Custom-designed printed outfits 🧩 Personalized facial details and accessories 💎 Glossy plastic textures and realistic 3D rendering 📸 Premium miniature-style studio photography One photo. One miniature version of you. A whole new way to see yourself in LEGO form. Upload your photo and build your minifigure!
1{2 "prompt": {3 "title": "Personalized LEGO Minifigure Collectible Generator",...+165 more lines

Photoreal Halloween night: five glowing carved jack-o'-lanterns (toothy grins, a surprised face, a smiling face, and a black cat silhouette) on weathered porch steps of an old white farmhouse, with white mini pumpkins, a straw broom, a glowing brass lantern, scattered maple leaves, bare trees, and a small full moon in a dark blue sky.
A photorealistic Halloween night photograph of five glowing carved jack-o'-lanterns on the weathered wooden porch steps of an old white clapboard farmhouse, wide landscape 16:9. On the upper step: one with triangle eyes and a jagged toothy grin, and one with round surprised eyes and an open O-shaped mouth. On the lower step: triangle eyes and a toothy grin on the far left, smiling crescent eyes in the center, and on the right a carved black cat silhouette with an arched back, backlit by candlelight. Three small white mini pumpkins sit on the top step at the right, beside a white painted porch post with an old straw broom leaning against it. A white wooden railing runs down the left side, and a glowing old brass hurricane lantern hangs on the house wall at the upper left. Dry orange and red maple leaves are scattered over the worn grey steps. Beyond the porch, bare tree silhouettes stand against a dark blue night sky with a small full moon behind thin clouds at the upper right. Low camera angle, shallow depth of field, warm candle glow against cool blue night, cozy and festive mood. No people, no text, no logos.
Sorts a search terms report into KEEP or EXCLUDE, and only excludes a term when it can never become a customer.
You are a senior Google Ads specialist auditing a search terms report. Business context: What the company sells, to whom, in which countries, and what it does not sell Search terms report (term, campaign, clicks, cost, conversions): search_terms Classify every search term as KEEP or EXCLUDE. There is no third category. Rules: 1. Judge what the person wants, not the words they typed. A clumsy phrasing can still come from a buyer. 2. EXCLUDE only when you are 100% certain the term can never become a customer: wrong product, wrong country, job seekers, free-only intent, an existing customer looking for support. 3. Any doubt means KEEP. A wasted click costs a few euros; a negative keyword on a real buyer costs every future sale on that term. 4. A term that already converted stays KEEP. 5. Do not invent context. If the business context does not settle it, the term stays KEEP. Output: 1. A table: term | decision | intent you read | reason in one sentence. 2. The shortest list of negative keywords that covers every EXCLUDE term, each with its match type (phrase or exact) and one KEEP term it must not block. 3. The total cost of the EXCLUDE terms, calculated from the report.

Photoreal wildlife shot: a snow leopard stands on snow-dusted dark rocks in the left foreground, body angled to the right and head turned to look straight at the camera, smoky grey fur with black rosettes and a long ringed tail, above a white sea of clouds with a range of jagged snowy Himalayan peaks glowing pink and gold at sunrise under a pale lavender sky.
A photorealistic wildlife photograph of a snow leopard standing on a snow-dusted rocky ridge high above a sea of clouds at dawn, in a wide landscape 16:9 frame. The snow leopard stands in the left half of the foreground on jagged dark grey rocks lightly covered with fresh snow and frost, its body angled across the frame toward the right while its head on the left turns to look straight into the camera with calm, pale grey-green eyes. Its thick, smoky grey and cream fur is covered with crisp black rosettes and spots, its large paws rest on the rocks, and its long, very thick ringed tail curves down to the right and hangs over the rocks. Behind the ridge, a soft white sea of clouds fills the valleys below. In the distance, a long range of jagged snow-covered Himalayan peaks stretches across the frame, their summits and faces lit pink, peach, and gold by the first light of sunrise, with cool blue shadows in the gullies, and a darker forested slope rising in the lower right. The sky is a clear pale blue fading to soft lavender near the horizon. Telephoto lens look, sharp focus on the leopard's face and fur, natural colors, quiet and majestic mood. No people, no text, no logos.
Interviews a founder with the 1Sentence Deck questions, drafts three one-sentence investment theses, builds the deck story, and runs the Repeat Test. From 1sentencedeck.com.
You are running the 1Sentence Deck method from 1sentencedeck.com. Follow it exactly. Interview me using the method’s own questions, one at a time. Wait for my answer before the next question. Never invent numbers or facts for me. The questions, in order: Why are you doing this? For who, who exactly has this problem and how many exist? Why aren’t existing solutions working? How are you doing it? How does it become a business, how do you reach customers and sell? Does your math work, what are the cost and revenue signals? If it’s working, where’s the evidence? Why you, when there are tons of alternatives and giant companies that could copy this? Can this become a huge outcome? Is this a good time? What do you need to get there? What’s your next milestone? From my answers, draft three deck thesis candidates. Each is one sentence in investor language, not customer language. Not what I sell. Why this becomes a huge company. Each must be repeatable (an investor could say it from memory tomorrow) and doubtable (specific enough to challenge). Tell me the tradeoffs and let me pick or edit one. Build the story. Write my answer to each question as one full sentence. These sentences are the headlines. Keep the question order as the default story order, and adjust only where my situation moves a doubt earlier, for example evidence early because nobody knows me. Then propose where each answer lands: its own slide, a shared slide, or a single line on another slide. You are shaping a story, not filling a template. Every question must be answered somewhere. Be honest about holes. If I gave you no real proof for a question, say so plainly and tell me to either get the proof or shrink the thesis. Do not decorate weak claims. Write my ask as one line: raising X to achieve Y in Z timeframe. Finish with the Repeat Test. Show me the cover first: the chosen thesis, stated as the biggest thing in the deck. Then the ordered headlines with where each lands. Then check: does reading only the headlines re-prove the cover? And what is the one sentence an investor repeats to their partner tomorrow? If it’s not the cover, tell me to rewrite, and say what’s competing with it.
Latest Bots
Catches quality drops when you change an AI feature's prompt or model: runs your test cases, compares old and new answers side by side, and tracks cost and speed.
Runs a school or activity carpool with other families: builds a fair driving rota, handles swaps and sick days, and reminds each driver the night before.
Reviews team expense reports against your company policy before you approve them: checks receipts, limits and duplicates, and explains every flag in plain words.
A Thermomix TM7 chef that turns any dish into step-by-step TM7 instructions, and can publish your saved recipes to your own bilingual Spacefast recipe site.
Plans realistic load tests before a launch or traffic spike: models expected traffic, writes test scenarios, reads the results and points to the first bottleneck to fix.
Latest Prompts
Prompt
A cute big gray elephant with huge ears and a small trunk standing happily in a beautiful green jungle, colorful trees and flowers, warm sunlight, friendly expression, 3D cartoon animation style, Pixar-like quality, cinematic lighting, vibrant colors, detailed, 16:9
To write Chapter 3 methodology section for BIM based quality managament title.Written below are research questions and objectives. Research Questions RQ1: How are construction QA/QC processes currently implemented in building projects in Yangon Region, and what information-management challenges and organizational and technological conditions affect these processes? RQ2: Which BIM-enabled information-management functions are applicable to each stage of the construction QA/QC process, from quality planning to approval and closure? RQ3: Based on the findings of RQ1 and RQ2, how can construction QA/QC activities, BIM-enabled information-management functions, stakeholder responsibilities, information requirements, approval procedures, and traceability mechanisms be integrated into a BIM-based construction QA/QC process framework? RQ4: How do construction industry experts evaluate the relevance, completeness, practicality, and contextual suitability of the proposed framework for building projects in Yangon Region? Objectives of the Study The general objective of this study is to develop and evaluate a BIM-based construction QA/QC process framework for building projects in Yangon Region, Myanmar. The specific objectives of this study are to: 1. Identify current construction QA/QC processes, associated information-management challenges, and the organizational and technological conditions affecting them in building projects in Yangon Region. 2. Identify BIM-enabled information-management functions applicable to each stage of construction QA/QC: quality planning, inspection request, site inspection and testing, conformance assessment, NCR/defect recording, corrective action, reinspection, and approval and closure. 3. Develop a BIM-based construction QA/QC process framework integrating QA/QC activities, BIM-enabled information-management functions, stakeholder responsibilities, information requirements, approval procedures, and traceability mechanisms. 4. Evaluate the relevance, completeness, practicality, and contextual suitability of the proposed framework through expert assessment.
Opinionated conventions for structuring, testing and shipping Unity game projects.
# Unity Game Project taste of a shipping-focused game developer - Pin the Unity editor to one LTS version per project and upgrade it deliberately in its own branch. Confidence: 0.90 - Use Git with Git LFS for binary assets, plus a .gitattributes file and Unity's smart merge tool for scenes and prefabs. Confidence: 0.85 - Set asset serialization to Force Text and keep visible meta files so changes can be reviewed and merged. Confidence: 0.90 - Split code into assembly definitions per feature to keep compile times short and dependencies explicit. Confidence: 0.85 - Keep gameplay logic in plain C# classes that can be unit-tested, with MonoBehaviours acting as thin adapters. Confidence: 0.80 - Use ScriptableObjects for designer-tunable data such as weapon stats and levels, not hard-coded constants. Confidence: 0.85 - Avoid GameObject.Find and string-based lookups at runtime; wire references in the inspector or through a simple composition root. Confidence: 0.85 - Cache component references in Awake instead of calling GetComponent every frame. Confidence: 0.90 - Avoid allocations in Update loops and use object pooling for bullets, effects and other frequently spawned objects. Confidence: 0.90 - Profile on the slowest target device with the Unity Profiler before optimizing, not only in the editor. Confidence: 0.90 - Load content with Addressables instead of the Resources folder, so memory and download size stay under control. Confidence: 0.80 - Use the new Input System with action maps, so keyboard, gamepad and touch share the same gameplay code. Confidence: 0.80 - Prefer async loading and coroutines or async/await for long operations, and never block the main thread on I/O. Confidence: 0.85 - Keep prefabs small and use prefab variants and nested prefabs instead of duplicating whole hierarchies. Confidence: 0.80 - Run Edit Mode and Play Mode tests with the Unity Test Framework in CI on every pull request. Confidence: 0.75 - Build every target platform automatically in CI, such as with GameCI, so broken platform builds are caught the same day. Confidence: 0.80 - Treat compiler warnings and console errors as bugs, and keep the console clean in every scene. Confidence: 0.75 - Agree on fixed folder conventions like Art, Audio, Prefabs, Scenes and Scripts, and enforce them in review. Confidence: 0.70 - Make save data versioned and migrate old saves on load instead of breaking players' progress after an update. Confidence: 0.85 - Strip debug menus and cheats from release builds with scripting define symbols. Confidence: 0.80
Opinionated conventions for idempotent, testable and safe Ansible playbooks and roles.
# Ansible Playbook taste of a pragmatic infrastructure engineer - Organize work into roles with clear defaults, and keep playbooks thin, mostly a list of roles applied to host groups. Confidence: 0.90 - Use fully qualified collection names like ansible.builtin.copy in every task so modules are never ambiguous. Confidence: 0.85 - Pin collection and role versions in a requirements.yml file and install them in CI from that file only. Confidence: 0.90 - Write every task to be idempotent, so a second run reports zero changes on a converged host. Confidence: 0.95 - Prefer purpose-built modules over shell or command; when shell is unavoidable, set creates, removes or changed_when explicitly. Confidence: 0.90 - Give every task a clear, human-readable name that says what state it ensures, not which command it runs. Confidence: 0.80 - Keep variables in group_vars and host_vars, and avoid setting the same variable in more than two places. Confidence: 0.85 - Prefix role variables with the role name, such as nginx_worker_processes, to avoid collisions between roles. Confidence: 0.85 - Store secrets in Ansible Vault or an external secret manager lookup, and never commit them in plain text. Confidence: 0.95 - Use handlers for service restarts so services restart once at the end, only when their configuration actually changed. Confidence: 0.90 - Template configuration files with Jinja2 and validate them before replacing, using the validate option where the service supports it. Confidence: 0.85 - Run ansible-lint and yamllint in CI on every pull request. Confidence: 0.85 - Test roles with Molecule against disposable containers or VMs for each supported operating system. Confidence: 0.75 - Use --check and --diff before applying changes to production, and review the diff like a code change. Confidence: 0.85 - Roll out to production with serial batches and max_fail_percentage so a bad change stops before it reaches every host. Confidence: 0.85 - Keep a dynamic inventory from the cloud provider or CMDB instead of hand-edited host lists. Confidence: 0.75 - Use tags sparingly and consistently, mainly to run one role or one concern, not to build hidden execution paths. Confidence: 0.70 - Avoid ignore_errors; use failed_when with a precise condition or a block with rescue when failure is expected. Confidence: 0.85 - Set become only on the tasks that need root, not globally for the whole play. Confidence: 0.75 - Enable SSH pipelining and tune forks to cut run time, and measure with the profile_tasks callback before optimizing further. Confidence: 0.70 - Run playbooks from CI or a controlled runner such as AWX, not from personal laptops, so every change is logged. Confidence: 0.80
Opinionated conventions for reliable, testable and updatable embedded firmware on microcontrollers.
# Embedded Firmware taste of a reliability-minded firmware engineer - Keep hardware access behind a thin hardware abstraction layer so application logic can be built and unit-tested on the host machine. Confidence: 0.90 - Run unit tests on the host in CI with Unity or GoogleTest, and keep on-target tests for drivers and timing only. Confidence: 0.85 - Avoid dynamic memory allocation after startup; allocate buffers statically or from fixed-size pools sized at compile time. Confidence: 0.90 - Use a small RTOS such as Zephyr or FreeRTOS once there are more than two independent timing concerns, instead of growing a hand-written super loop. Confidence: 0.75 - Keep interrupt handlers short: capture the data, set a flag or push to a queue, and do the real work in a task. Confidence: 0.95 - Mark data shared with interrupts as volatile and guard multi-byte access with critical sections or atomics. Confidence: 0.90 - Enable the hardware watchdog in production builds and feed it only from a health check that confirms every critical task is alive. Confidence: 0.90 - Build with -Wall -Wextra -Werror and run a static analyzer such as cppcheck or clang-tidy on every pull request. Confidence: 0.85 - Pin the exact cross-compiler version in a container image or toolchain file so every build is reproducible. Confidence: 0.90 - Use fixed-width integer types like uint32_t everywhere hardware registers, protocols or storage formats are involved. Confidence: 0.90 - Track flash and RAM usage per build in CI and fail when either crosses an agreed budget. Confidence: 0.80 - Ship over-the-air updates with an A/B partition scheme, signed images and automatic rollback if the new image fails to boot. Confidence: 0.90 - Verify firmware signatures in the bootloader with a key the application cannot overwrite. Confidence: 0.85 - Lock debug ports such as JTAG and SWD on production units, with a documented process for authorized recovery. Confidence: 0.80 - Store a crash record with the program counter, fault registers and reset reason in retained RAM and report it on the next boot. Confidence: 0.85 - Use structured, compact logging with log levels and a ring buffer, and avoid printf in interrupt context. Confidence: 0.80 - Write wear-aware storage code: batch writes, use a flash file system like littlefs, and never rewrite the same sector in a tight loop. Confidence: 0.85 - Version configuration stored in flash and migrate it on boot, rather than assuming the layout never changes. Confidence: 0.80 - Design for low power from the start: sleep by default, wake on interrupts, and measure current draw with real hardware. Confidence: 0.75 - Run a hardware-in-the-loop test rig in CI for the main boards before releasing a firmware version. Confidence: 0.70 - Embed the git commit hash and build time in every image and expose them through a version command or register. Confidence: 0.85
Plans your reading day by day so you finish every book before its book club, course or exam date - from page or word counts (or audiobook length), your reading speed, the minutes you have each weekday, trips and busy days. Spreads pages evenly, keeps buffer days before each deadline and weekly catch-up days, stops at chapter ends, warns when a deadline is out of reach and re-plans from where you are. Includes a stdlib Python planner.
---
name: reading-plan-and-page-pace-planner
description: Plans your reading day by day so you finish every book before its book club, course or exam date - from page or word counts (or audiobook length), your reading speed, the minutes you have each weekday, trips and busy days. Spreads pages evenly, keeps buffer days before each deadline and weekly catch-up days, stops at chapter ends, warns when a deadline is out of reach and re-plans from where you are. Includes a stdlib Python planner.
---
# Reading Plan and Page Pace Planner
You help readers stop cramming 200 pages the night before book club. People know roughly how much time they have (half an hour on weekdays, more at the weekend) but not what that means in pages for a 245-page novel due in two weeks, a dense work book and an 880-page classic in the same month. You turn books, reading speed and free minutes into a concrete daily plan ("Tue: p. 1-27"), keep slack for real life, and say early and plainly when a deadline cannot be met and what would fix it.
## Files in this skill
- `scripts/reading_plan.py` - reads a plan JSON (books with pages, words or audiobook minutes, start page, difficulty, deadline, chapter starts; reading speed in words per minute or pages per hour; minutes per weekday; trips and exceptions; catch-up weekdays; buffer days); schedules earliest deadline first, spreads the load evenly (or as soon as possible), snaps daily stops to chapter ends, and prints a book summary, findings RP01-RP06, a day-by-day table with page ranges, weekly checkpoints and catch-up advice; `--from` + `--at "Title=page"` re-plans mid-way (Python 3.8+ standard library only)
- `references/reading-speed-and-estimates.md` - measuring your speed, words per page by book type, difficulty factors, audiobooks
- `references/pacing-and-catch-up.md` - buffers, catch-up days, what to do when you fall behind or a deadline is impossible
- `templates/reading-plan.md` - the plan to hand back (calendar-friendly)
- `examples/book-club.json` - November-December: a novel for a Thursday book club, a work book already started at p. 23, Middlemarch for a classics club, an audiobook with no deadline, Sunday catch-up days, a weekend trip and two unusual days
- `examples/book-club-plan.md` - the real planner output on that file
## Workflow
### 1. Collect the inputs
Ask for: each book with its page count (or word count; the edition matters), where they are now, the deadline and what it is (meeting, exam), whether it is easy or dense; their reading speed (or time a 10-minute test with `references/reading-speed-and-estimates.md`); realistic minutes per weekday, not ideal ones; known trips, late shifts and free days; which weekday should stay free as a catch-up day; and how many days before each meeting they want to be done (buffer, default 1-2).
### 2. Run the planner
```
python3 scripts/reading_plan.py examples/book-club.json
python3 scripts/reading_plan.py examples/book-club.json --mode asap
python3 scripts/reading_plan.py examples/book-club.json --from 2026-11-20 --at "Thinking in Systems=110"
python3 scripts/reading_plan.py plan.json --json
python3 scripts/reading_plan.py --self-test
```
Exit 0 = every deadline met, 1 = at least one book cannot be finished in time, 2 = bad input. Without a shell: minutes needed = pages x words per page / words per minute x difficulty; compare with the minutes available up to each finish-by date (deadline minus buffer, catch-up days excluded), earliest deadline first.
### 3. Read the result
- RP01 (high): a book does not fit before its deadline. The message gives the shortfall in pages and the extra minutes per reading day that would close it. When the plan is infeasible the planner reads at full pace before it, so earlier books finish early to free time.
- RP02 / RP04 (medium): the plan only works by eating the buffer, or uses over 90% of your time in its busiest stretch - one bad week breaks it.
- RP03 (low): days longer than the max session - split them. RP05/RP06 (info): finished books, close deadlines, audiobooks.
- Check the day table for heavy weekend days and the weekly checkpoints for "where should I be by Sunday".
### 4. Fix an impossible plan
Offer options in this order: start the long book now in parallel (move its deadline logic earlier), add 10-20 minutes on some days, use the catch-up days, switch part of it to the audiobook for commutes, skim or skip clearly marked sections, or accept arriving at the meeting part-way and say so. Re-run after each change. See `references/pacing-and-catch-up.md`.
### 5. Deliver
Fill `templates/reading-plan.md`: per-book summary, the week-by-week plan (dates and page ranges, ready for a calendar or a bookmark note), the catch-up rule, and the re-plan command. Offer a quick re-plan whenever they report their current page.
## Example result
On `examples/book-club.json` (240 wpm, 20-60 min a day, Sundays free for catch-up, 2 buffer days) *The Remains of the Day* is done on 12 Nov for the 19 Nov book club, stopping at chapter ends, and *Thinking in Systems* (dense, factor 1.3, from p. 23) on 27 Nov for the 1 Dec group. *Middlemarch* (316,000 words, about 22 hours) cannot be finished by the 17 Dec classics club: about 452 pages short, projected finish 11 Jan (RP01, high). Fix: about +17 minutes on each of the 42 reading days, or start it alongside the other two now. The 120-minute quiet Saturday is flagged as two sessions (RP03), and the Piranesi audiobook (6 h 40 at 1.25x) follows after Middlemarch.
## Rules
- Plan with the time people really have, and keep at least one buffer day before every meeting.
- Never quietly overfill days: if it does not fit, say which deadline fails, by how many pages, and what would fix it.
- Use the edition's page count; when only word counts are known, say that pages are estimates.
- Leave catch-up days empty in the plan - they are for falling behind, not for more reading.
- Reading should stay enjoyable: suggest skipping or switching formats before suggesting 2-hour sessions every day.
- Re-plan from the current page whenever the reader is behind or ahead; do not make them "catch up" to an old schedule.
FILE:references/reading-speed-and-estimates.md
# Reading speed and estimates
## Measure your speed (10 minutes)
1. Pick the actual book (or one like it), start at a chapter start, set a 10-minute timer and read normally.
2. Count the words on three full lines, divide by 3 = words per line; count the lines on a full page = lines per page.
3. words per page = words per line x lines per page; words per minute = pages read x words per page / 10.
Or simply time a few pages and use `"pages_per_hour"` instead of words per minute.
## Typical values
| Reader / material | Words per minute |
|---|---|
| Careful reading, dense non-fiction, second language | 150-200 |
| Average adult, fiction | 200-260 |
| Fast leisure reader | 300-400 |
Skimming is not reading: do not plan with speeds above 450.
| Book type | Words per page |
|---|---|
| Mass-market paperback | 300-350 |
| Trade paperback fiction | 250-300 |
| Hardcover non-fiction with notes | 350-450 |
| Large print, children's chapter books | 150-200 |
| E-book | use the word count (store page or tools that show it) and the default 275 words per page |
## Difficulty factor
`"difficulty"` multiplies reading time: 1.0 easy fiction, 1.2-1.4 dense non-fiction, classics with long sentences, technical books with diagrams, 1.5+ textbooks you take notes on, poetry, second-language reading.
## Page count vs word count
- Use the page count of the edition the reader holds; editions of the same book can differ by 30%.
- With both `"pages"` and `"words"`, the planner derives the words per page of that edition (Middlemarch 316,000 / 880 = 359).
- With only words, pages are estimates (words / words per page); say so in the plan.
## Audiobooks
- `"audio_minutes"` is the runtime, `"speed"` the playback speed: time needed = runtime / speed (400 min at 1.25x = 320 min).
- Audio fits commutes, chores and walks: say whether those minutes are extra or already counted in the daily minutes.
- Positions are shown as h:mm of the audio.
FILE:references/pacing-and-catch-up.md
# Pacing and catch-up
## How the planner schedules
- Earliest deadline first: the book with the nearest finish-by date (deadline minus buffer days) is read first; books without a deadline come last.
- Even mode (default): for each stretch up to the tightest deadline it uses the smallest equal share of your daily minutes that still finishes in time, so 30-minute days might become 20-minute days when there is room. After that deadline the next stretch is computed again.
- ASAP mode: uses all the minutes every day; books finish as early as possible.
- If a deadline cannot be met even at full pace, every day before it is planned at full pace and the book is flagged RP01.
- Daily stops move to a chapter end when one is within `chapter_snap_pages` (default 8) and a quarter of the day's pages.
## Buffers and catch-up days
- Buffer days (`buffer_days`, default 1) sit between the planned finish and the meeting: time to reread notes or absorb a bad week.
- Catch-up weekdays (`catch_up_days`, e.g. Sunday) get no planned reading. Use them only when behind; if you are on track they are free time.
- Exceptions (trips, late shifts, a quiet Saturday) override the weekday minutes for those dates.
## When you fall behind
1. Missed one day: read that day's pages on the next catch-up day, or add a third of them to each of the next three days.
2. Behind by more than two days: re-plan from today with `--from <today> --at "<Title>=<current page>"` instead of chasing the old plan.
3. Still not in time (RP01): pick one - start the long book in parallel now, add 10-20 minutes on a few days, use the audiobook for commutes, skim marked sections (prefaces, appendices, notes), or decide to arrive part-way and tell the group.
## Signs the plan is too tight
- RP04: over 90% of your time used in a stretch; any illness or late week breaks it.
- RP02: the book only fits by using the buffer days.
- Many days marked "2 sessions" (RP03): consider spreading reading across morning and evening on purpose.
## Book club tips
- Note the page or chapter the group will discuss if it is not the whole book, and use that as the book's `pages`.
- For long classics, ask the club whether the meeting covers the first half; plan two deadlines.
- Keep a sticky note in the book with the weekly checkpoints from the plan.
FILE:templates/reading-plan.md
# Reading plan: <name> (<start> to <last deadline>)
**Pace:** <wpm> words/min, about <pages> pages in a 30-minute session
**Time:** <minutes per weekday>; catch-up day(s): <day>; buffer: <n> day(s) before each meeting
**Verdict:** <every deadline is met | BOOK cannot be finished by DATE - see options>
## Books
| Book | Pages left | Time needed | Meeting / deadline | Finish by | Planned finish |
|---|---|---|---|---|---|
| <title> | <pp> | <h:mm> | <date - event> | <date> | <date> |
## Week by week
### Week of <Mon date>
- Mon: <Book p. a-b>
- Tue: <...>
- Sun: catch-up / free
**Checkpoint Sunday night:** <Book p. X>
## If a deadline does not fit
- Shortfall: <pages> pages of <book> by <date>
- Options: <+N min on reading days | start now in parallel | audiobook for commutes | skim chapters x-y | arrive part-way>
- Chosen: <...>
## Falling behind
- Missed a day? Use <catch-up day> or add <n> pages to the next three days.
- More than two days behind: re-plan from today (`--from <date> --at "<title>=<page>"`).
FILE:examples/book-club.json
{
"name": "Reading plan: Nov-Dec 2026 (two book clubs and a work book)",
"start": "2026-11-03",
"reader": {"words_per_minute": 240, "words_per_page": 280},
"minutes": {"mon": 30, "tue": 30, "wed": 20, "thu": 30, "fri": 25, "sat": 60, "sun": 45},
"catch_up_days": ["sun"],
"buffer_days": 2,
"max_session_minutes": 60,
"chapter_snap_pages": 8,
"exceptions": [
{"dates": "2026-11-13..2026-11-15", "minutes": 0, "note": "weekend trip"},
{"dates": "2026-11-26", "minutes": 15, "note": "late shift"},
{"dates": "2026-12-05", "minutes": 120, "note": "quiet Saturday"}
],
"books": [
{"title": "The Remains of the Day", "pages": 245, "words_per_page": 265, "deadline": "2026-11-19", "event": "Thursday book club",
"chapters": [1, 9, 45, 101, 125, 165, 212, 239]},
{"title": "Thinking in Systems", "pages": 218, "start_page": 23, "difficulty": 1.3, "deadline": "2026-12-01", "event": "team reading group"},
{"title": "Middlemarch", "words": 316000, "pages": 880, "deadline": "2026-12-17", "event": "classics club"},
{"title": "Piranesi (audiobook)", "audio_minutes": 400, "speed": 1.25}
]
}
FILE:examples/book-club-plan.md
# Reading plan: Nov-Dec 2026 (two book clubs and a work book)
Start 2026-11-03, even mode, 2 buffer day(s) before each deadline, catch-up days: sun. Planned reading: 36 h 44 min over 81 days.
**Verdict: 1 book(s) cannot be finished by their deadline - change the plan**
## Books
| Book | Left | Est. time | Deadline | Finish by | Planned finish | Days spare | Avg per reading day |
|---|---|---|---|---|---|---|---|
| The Remains of the Day | 245 pp | 4:31 h | 2026-11-19 (Thursday book club) | 2026-11-17 | 2026-11-12 | 7 | 27 pp |
| Thinking in Systems | 196 pp | 4:57 h | 2026-12-01 (team reading group) | 2026-11-29 | 2026-11-27 | 4 | 16 pp |
| Middlemarch | 880 pp | 21:57 h | 2026-12-17 (classics club) | 2026-12-15 | 2027-01-11 | -25 | 23 pp |
| Piranesi (audiobook) | 6:40 audio | 5:20 h | - | - | 2027-01-22 | - | 0:36 |
## Findings
- **HIGH RP01** Middlemarch cannot be finished by 2026-12-17 (classics club): about 452 pages short at your pace; projected finish 2027-01-11. Options: +17 min on each of the 42 reading days until then, read faster, skim or skip about 452 pages, or start it earlier
- **LOW RP03** 1 day(s) plan more than 60 minutes (e.g. 2026-12-05: 120 min) - split into two sessions
- **INFO RP06** Piranesi (audiobook) is an audiobook: the plan counts listening time at 1.25x speed; commutes and chores can cover it
## Day by day
| Date | Day | Minutes | Read | Note |
|---|---|---|---|---|
| 2026-11-03 | Tue | 30 | The Remains of the Day p. 1-27 (27 pp) | |
| 2026-11-04 | Wed | 19 | The Remains of the Day p. 28-44 (17 pp) | |
| 2026-11-05 | Thu | 30 | The Remains of the Day p. 45-71 (27 pp) | |
| 2026-11-06 | Fri | 25 | The Remains of the Day p. 72-94 (23 pp) | |
| 2026-11-07 | Sat | 60 | The Remains of the Day p. 95-148 (54 pp) | |
| 2026-11-08 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-11-09 | Mon | 30 | The Remains of the Day p. 149-175 (27 pp) | |
| 2026-11-10 | Tue | 30 | The Remains of the Day p. 176-202 (27 pp) | |
| 2026-11-11 | Wed | 20 | The Remains of the Day p. 203-221 (19 pp) | |
| 2026-11-12 | Thu | 30 | The Remains of the Day p. 222-245 (24 pp); Thinking in Systems p. 23-24 (2 pp) | |
| 2026-11-13 | Fri | - | - | weekend trip |
| 2026-11-14 | Sat | - | - | weekend trip |
| 2026-11-15 | Sun | - | - | weekend trip |
| 2026-11-16 | Mon | 30 | Thinking in Systems p. 25-44 (20 pp) | |
| 2026-11-17 | Tue | 30 | Thinking in Systems p. 45-64 (20 pp) | finish-by The Remains of the Day |
| 2026-11-18 | Wed | 20 | Thinking in Systems p. 65-77 (13 pp) | |
| 2026-11-19 | Thu | 30 | Thinking in Systems p. 78-97 (20 pp) | DEADLINE The Remains of the Day - Thursday book club |
| 2026-11-20 | Fri | 25 | Thinking in Systems p. 98-113 (16 pp) | |
| 2026-11-21 | Sat | 60 | Thinking in Systems p. 114-153 (40 pp) | |
| 2026-11-22 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-11-23 | Mon | 30 | Thinking in Systems p. 154-172 (19 pp) | |
| 2026-11-24 | Tue | 30 | Thinking in Systems p. 173-192 (20 pp) | |
| 2026-11-25 | Wed | 20 | Thinking in Systems p. 193-205 (13 pp) | |
| 2026-11-26 | Thu | 15 | Thinking in Systems p. 206-215 (10 pp) | late shift |
| 2026-11-27 | Fri | 25 | Thinking in Systems p. 216-218 (3 pp); Middlemarch p. 1-14 (14 pp) | |
| 2026-11-28 | Sat | 60 | Middlemarch p. 15-54 (40 pp) | |
| 2026-11-29 | Sun | - | - | catch-up / spare (45 min available); finish-by Thinking in Systems |
| 2026-11-30 | Mon | 30 | Middlemarch p. 55-74 (20 pp) | |
| 2026-12-01 | Tue | 30 | Middlemarch p. 75-94 (20 pp) | DEADLINE Thinking in Systems - team reading group |
| 2026-12-02 | Wed | 20 | Middlemarch p. 95-107 (13 pp) | |
| 2026-12-03 | Thu | 30 | Middlemarch p. 108-128 (21 pp) | |
| 2026-12-04 | Fri | 25 | Middlemarch p. 129-144 (16 pp) | |
| 2026-12-05 | Sat | 120 | Middlemarch p. 145-224 (80 pp) | quiet Saturday; 2 sessions |
| 2026-12-06 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-07 | Mon | 30 | Middlemarch p. 225-244 (20 pp) | |
| 2026-12-08 | Tue | 30 | Middlemarch p. 245-265 (21 pp) | |
| 2026-12-09 | Wed | 20 | Middlemarch p. 266-278 (13 pp) | |
| 2026-12-10 | Thu | 30 | Middlemarch p. 279-298 (20 pp) | |
| 2026-12-11 | Fri | 25 | Middlemarch p. 299-315 (17 pp) | |
| 2026-12-12 | Sat | 60 | Middlemarch p. 316-355 (40 pp) | |
| 2026-12-13 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-14 | Mon | 30 | Middlemarch p. 356-375 (20 pp) | |
| 2026-12-15 | Tue | 30 | Middlemarch p. 376-395 (20 pp) | finish-by Middlemarch |
| 2026-12-16 | Wed | 20 | Middlemarch p. 396-408 (13 pp) | |
| 2026-12-17 | Thu | 30 | Middlemarch p. 409-428 (20 pp) | DEADLINE Middlemarch - classics club |
| 2026-12-18 | Fri | 25 | Middlemarch p. 429-445 (17 pp) | |
| 2026-12-19 | Sat | 60 | Middlemarch p. 446-485 (40 pp) | |
| 2026-12-20 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-21 | Mon | 30 | Middlemarch p. 486-505 (20 pp) | |
| 2026-12-22 | Tue | 30 | Middlemarch p. 506-525 (20 pp) | |
| 2026-12-23 | Wed | 20 | Middlemarch p. 526-539 (14 pp) | |
| 2026-12-24 | Thu | 30 | Middlemarch p. 540-559 (20 pp) | |
| 2026-12-25 | Fri | 25 | Middlemarch p. 560-575 (16 pp) | |
| 2026-12-26 | Sat | 60 | Middlemarch p. 576-615 (40 pp) | |
| 2026-12-27 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-28 | Mon | 30 | Middlemarch p. 616-635 (20 pp) | |
| 2026-12-29 | Tue | 30 | Middlemarch p. 636-656 (21 pp) | |
| 2026-12-30 | Wed | 20 | Middlemarch p. 657-669 (13 pp) | |
| 2026-12-31 | Thu | 30 | Middlemarch p. 670-689 (20 pp) | |
| 2027-01-01 | Fri | 25 | Middlemarch p. 690-706 (17 pp) | |
| 2027-01-02 | Sat | 60 | Middlemarch p. 707-746 (40 pp) | |
| 2027-01-03 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-04 | Mon | 30 | Middlemarch p. 747-766 (20 pp) | |
| 2027-01-05 | Tue | 30 | Middlemarch p. 767-786 (20 pp) | |
| 2027-01-06 | Wed | 20 | Middlemarch p. 787-799 (13 pp) | |
| 2027-01-07 | Thu | 30 | Middlemarch p. 800-819 (20 pp) | |
| 2027-01-08 | Fri | 25 | Middlemarch p. 820-836 (17 pp) | |
| 2027-01-09 | Sat | 60 | Middlemarch p. 837-876 (40 pp) | |
| 2027-01-10 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-11 | Mon | 30 | Middlemarch p. 877-880 (4 pp); Piranesi (audiobook) 0:00-0:30 | |
| 2027-01-12 | Tue | 30 | Piranesi (audiobook) 0:30-1:08 | |
| 2027-01-13 | Wed | 20 | Piranesi (audiobook) 1:08-1:33 | |
| 2027-01-14 | Thu | 30 | Piranesi (audiobook) 1:33-2:10 | |
| 2027-01-15 | Fri | 25 | Piranesi (audiobook) 2:10-2:41 | |
| 2027-01-16 | Sat | 60 | Piranesi (audiobook) 2:41-3:56 | |
| 2027-01-17 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-18 | Mon | 30 | Piranesi (audiobook) 3:56-4:34 | |
| 2027-01-19 | Tue | 30 | Piranesi (audiobook) 4:34-5:11 | |
| 2027-01-20 | Wed | 20 | Piranesi (audiobook) 5:11-5:36 | |
| 2027-01-21 | Thu | 30 | Piranesi (audiobook) 5:36-6:14 | |
| 2027-01-22 | Fri | 21 | Piranesi (audiobook) 6:14-6:40 | |
## Weekly checkpoints (where you should be by Sunday night)
- 2026-11-08: The Remains of the Day p. 148
- 2026-11-15: Thinking in Systems p. 24
- 2026-11-22: Thinking in Systems p. 153
- 2026-11-29: Middlemarch p. 54
- 2026-12-06: Middlemarch p. 224
- 2026-12-13: Middlemarch p. 355
- 2026-12-20: Middlemarch p. 485
- 2026-12-27: Middlemarch p. 615
- 2027-01-03: Middlemarch p. 746
- 2027-01-10: Middlemarch p. 876
- 2027-01-17: Piranesi (audiobook) 3:56
## If you fall behind
- The Remains of the Day: a missed day costs about 27 pages. Make them up on the spare day(s) 2026-11-08, or add 10 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "The Remains of the Day=<page>"`.
- Thinking in Systems: a missed day costs about 16 pages. Make them up on the spare day(s) 2026-11-22, 2026-11-29, or add 6 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "Thinking in Systems=<page>"`.
- Middlemarch: a missed day costs about 23 pages. Make them up on the spare day(s) 2026-11-29, 2026-12-06, 2026-12-13, or add 8 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "Middlemarch=<page>"`.
FILE:scripts/reading_plan.py
#!/usr/bin/env python3
"""Reading plan and page pace planner (stdlib only, Python 3.8+).
Turns a list of books (pages, word counts or audiobook length), your reading speed and the
minutes you have each day into a day-by-day plan with page ranges that meets every deadline
(book club, course, exam), keeps buffer days before each deadline and catch-up days in the
week, spreads the load evenly and tells you what to do if you fall behind.
Usage:
reading_plan.py PLAN.json [--mode even|asap] [--from YYYY-MM-DD] [--at "Title=page"]... [--json]
reading_plan.py --self-test
Exit: 0 = every deadline is met, 1 = at least one book cannot be finished by its deadline,
2 = bad input.
"""
import argparse, datetime, json, math, re, sys
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
class InputError(Exception):
pass
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s))
except ValueError:
raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))
def expand_dates(spec):
spec = str(spec)
if ".." in spec:
a, b = spec.split("..", 1)
a, b = pdate(a.strip(), "date range"), pdate(b.strip(), "date range")
if b < a:
raise InputError("date range %s ends before it starts" % spec)
return [a + datetime.timedelta(days=i) for i in range((b - a).days + 1)]
return [pdate(spec.strip(), "date")]
# ---------------------------------------------------------------- model
def load(cfg, start_override=None, at=None):
if not isinstance(cfg, dict) or not isinstance(cfg.get("books"), list) or not cfg["books"]:
raise InputError("plan needs a non-empty 'books' list")
reader = cfg.get("reader") or {}
wpm = float(reader.get("words_per_minute", 230))
wpp_default = float(reader.get("words_per_page", 275))
pph = reader.get("pages_per_hour")
if wpm <= 0 or wpp_default <= 0:
raise InputError("words_per_minute and words_per_page must be positive")
start = pdate(start_override or cfg.get("start") or datetime.date.today().isoformat(), "start")
mins = cfg.get("minutes")
if isinstance(mins, (int, float)):
mins = {d: mins for d in DAYS}
if not isinstance(mins, dict):
raise InputError("'minutes' must be a number or {mon: .., tue: ..}")
mins = {k.lower()[:3]: float(v) for k, v in mins.items()}
bad = set(mins) - set(DAYS)
if bad:
raise InputError("unknown weekday key(s) in minutes: %s" % ", ".join(sorted(bad)))
catch = {d.lower()[:3] for d in cfg.get("catch_up_days", [])}
exc = {}
for e in cfg.get("exceptions", []):
for d in expand_dates(e.get("dates") or e.get("date")):
exc[d] = (float(e.get("minutes", 0)), e.get("note", ""))
books = []
at = at or {}
for i, b in enumerate(cfg["books"], 1):
t = b.get("title") or "Book %d" % i
if "audio_minutes" in b:
speed = float(b.get("speed", 1.0))
total = float(b["audio_minutes"])
unit_min = 1.0 / speed # minutes of your time per minute of audio
units, kind = total, "audio"
done = float(at.get(t, b.get("start_minute", 0)))
else:
wpp = float(b.get("words_per_page") or (float(b["words"]) / float(b["pages"]) if b.get("words") and b.get("pages") else wpp_default))
pages = float(b["pages"]) if b.get("pages") else (float(b["words"]) / wpp if b.get("words") else None)
if not pages or pages <= 0:
raise InputError("book %r needs pages, words or audio_minutes" % t)
diff = float(b.get("difficulty", 1.0))
if pph and not b.get("words"):
unit_min = 60.0 / float(pph) * diff
else:
unit_min = wpp / wpm * diff
units, kind = round(pages), "pages"
sp = at.get(t, b.get("start_page", 1))
done = max(0.0, float(sp) - 1)
dl = pdate(b["deadline"], "deadline of %r" % t) if b.get("deadline") else None
chapters = sorted(int(c) for c in b.get("chapters", []))
books.append({"title": t, "units": float(units), "kind": kind, "unit_min": unit_min, "done": min(done, float(units)),
"deadline": dl, "event": b.get("event", ""), "chapters": chapters, "order": i})
buf = int(cfg.get("buffer_days", 1))
return {"start": start, "mins": mins, "catch": catch, "exc": exc, "books": books, "buffer": buf,
"max_session": float(cfg.get("max_session_minutes", 0) or 0), "snap": int(cfg.get("chapter_snap_pages", 8)),
"name": cfg.get("name", "Reading plan"), "wpm": wpm}
def capacity(m, d):
"""(planned minutes, available minutes incl. catch-up, note)."""
if d in m["exc"]:
v, note = m["exc"][d]
return v, v, note or ("no reading" if v == 0 else "")
wd = DAYS[d.weekday()]
v = m["mins"].get(wd, 0.0)
if wd in m["catch"]:
return 0.0, v, "catch-up day"
return v, v, ""
def plan(m, mode="even"):
F = []
books = [b for b in m["books"]]
for b in books:
b["need"] = (b["units"] - b["done"]) * b["unit_min"]
if b["need"] <= 0:
F.append(("info", "RP05", "%s is already finished - left out" % b["title"]))
b["target"] = None
if b["deadline"] and b["deadline"] < m["start"]:
if b["need"] > 0:
F.append(("medium", "RP05", "%s: deadline %s has already passed - planned without a deadline" % (b["title"], b["deadline"])))
b["deadline"] = None
if b["deadline"]:
b["target"] = b["deadline"] - datetime.timedelta(days=m["buffer"])
if b["target"] < m["start"]:
b["target"] = m["start"]
F.append(("medium", "RP05", "%s: deadline %s is too close for %d buffer day(s) - planning to the deadline" % (b["title"], b["deadline"], m["buffer"])))
todo = [b for b in books if b["need"] > 0]
dated = sorted([b for b in todo if b["target"]], key=lambda b: (b["target"], b["order"]))
undated = [b for b in todo if not b["target"]]
seq = dated + undated
last = max([b["deadline"] for b in dated] or [m["start"]])
# horizon: enough days for everything at full capacity, plus slack
weekly = sum(m["mins"].get(d, 0) for d in DAYS if d not in m["catch"]) or 1
total_need = sum(b["need"] for b in seq)
horizon_end = max(last, m["start"]) + datetime.timedelta(days=int(total_need / weekly * 7) + 60)
days, d = [], m["start"]
while d <= horizon_end:
p, a, note = capacity(m, d)
days.append({"date": d, "cap": p, "avail": a, "note": note, "planned": 0.0, "items": []})
d += datetime.timedelta(days=1)
idx = {x["date"]: i for i, x in enumerate(days)}
# load factor per segment (even mode): smallest uniform share of capacity that meets every target
load = [1.0] * len(days)
if mode == "even" and dated:
seg_start, k0 = 0, 0
while k0 < len(dated):
best, bk = 0.0, k0
cum = 0.0
for k in range(k0, len(dated)):
cum += dated[k]["need"]
ti = idx[dated[k]["target"]]
cap = sum(x["cap"] for x in days[seg_start:ti + 1])
r = cum / cap if cap > 0 else float("inf")
if r >= best:
best, bk = r, k
L = min(best * 1.03, 1.0) # 3% headroom for chapter-end stops and rounding
end = idx[dated[bk]["target"]]
for i in range(seg_start, end + 1):
load[i] = L
seg_start, k0 = end + 1, bk + 1
# after the last deadline: undated books at the average load of the plan so far (at least 0.5)
tail = max(0.5, max(load[:seg_start] or [1.0]))
for i in range(seg_start, len(days)):
load[i] = tail if undated else 1.0
# allocate (earliest deadline first), tracking each book's position
qi = 0
pos = {b["title"]: b["done"] for b in seq}
for i, day in enumerate(days):
budget = day["cap"] * load[i]
while budget > 1e-9 and qi < len(seq):
b = seq[qi]
remaining = (b["units"] - pos[b["title"]]) * b["unit_min"]
use = min(budget, remaining)
before = pos[b["title"]]
after = before + use / b["unit_min"]
if after > b["units"] - 1e-6:
after = b["units"]
elif b["kind"] == "pages":
snapped = snap(after, b, m["snap"], use / b["unit_min"])
if snapped != after:
after = snapped
use = (after - before) * b["unit_min"]
budget = use # stop at the chapter end: the day ends here
pos[b["title"]] = after
day["items"].append({"title": b["title"], "from": before, "to": after, "kind": b["kind"], "minutes": use})
day["planned"] += use
budget -= use
if after >= b["units"] - 1e-6:
b["finish"] = day["date"]; qi += 1
elif budget <= 1e-9 or use <= 1e-9:
break
if qi >= len(seq):
break
days = [x for x in days if x["date"] <= max([b.get("finish", m["start"]) for b in seq] + [last])]
for b in seq:
b.setdefault("finish", None)
# findings
for b in dated:
f = b["finish"]
if f is None or f > b["deadline"]:
avail = [x for x in days if x["date"] <= b["deadline"]]
share = sum(it["minutes"] for x in avail for it in x["items"] if it["title"] == b["title"])
short_min = b["need"] - share
# also count catch-up/buffer time that would be available up to the deadline
reading_days = sum(1 for x in avail if x["avail"] > 0) or 1
short_units = short_min / b["unit_min"]
msg = "%s cannot be finished by %s%s: about %d %s short at your pace; projected finish %s. Options: +%d min on each of the %d reading days until then, read faster, skim or skip about %d %s, or start it earlier" % (
b["title"], b["deadline"], " (%s)" % b["event"] if b["event"] else "", math.ceil(short_units), "pages" if b["kind"] == "pages" else "audio minutes",
f or "beyond the plan", math.ceil(short_min / reading_days), reading_days, math.ceil(short_units), "pages" if b["kind"] == "pages" else "audio minutes")
F.append(("high", "RP01", msg))
elif f > b["target"]:
F.append(("medium", "RP02", "%s finishes %s, inside the %d buffer day(s) before %s - no room for a missed day" % (b["title"], f, m["buffer"], b["deadline"])))
if mode == "even":
peak = max(load) if load else 0
if 0.9 <= peak <= 1.0 and not any(f[1] == "RP01" for f in F):
F.append(("medium", "RP04", "the plan uses %d%% of your planned reading time in its busiest stretch - one bad week will push you into the buffer" % round(peak * 100)))
if m["max_session"]:
long = [x for x in days if x["planned"] > m["max_session"] + 0.5]
if long:
F.append(("low", "RP03", "%d day(s) plan more than %d minutes (e.g. %s: %d min) - split into two sessions" % (
len(long), m["max_session"], long[0]["date"], round(long[0]["planned"]))))
for b in seq:
if b["kind"] == "audio":
F.append(("info", "RP06", "%s is an audiobook: the plan counts listening time at %sx speed; commutes and chores can cover it" % (b["title"], ("%.2f" % (1 / b["unit_min"])).rstrip("0").rstrip("."))))
F.sort(key=lambda f: (SEV[f[0]], f[1]))
return {"days": days, "books": seq, "all_books": books, "findings": F, "load": load}
def snap(after, b, snap_pages, day_units):
"""Move a day's stopping point to a chapter end if one is close (within snap_pages and 25% of the day)."""
if not b["chapters"] or snap_pages <= 0:
return after
lim = min(snap_pages, max(1.0, day_units * 0.25))
ends = [c - 1 for c in b["chapters"] if c - 1 > 0]
best = None
for e in ends:
if abs(e - after) <= lim and e > after - day_units + 0.5 and (best is None or abs(e - after) < abs(best - after)):
best = e
return float(best) if best is not None else after
# ---------------------------------------------------------------- rendering
def fmt_item(it):
if it["kind"] == "audio":
return "%s %s-%s" % (it["title"], hm(it["from"]), hm(it["to"]))
a = int(round(it["from"])) + 1
z = int(round(it["to"]))
if z < a:
z = a
return "%s p. %d-%d (%d pp)" % (it["title"], a, z, z - a + 1)
def hm(x):
x = int(round(x))
return "%d:%02d" % (x // 60, x % 60)
def checkpoints(r):
out = []
pos = {}
for x in r["days"]:
for it in x["items"]:
pos[it["title"]] = it
if x["date"].weekday() == 6 and pos: # Sundays
out.append((x["date"], "; ".join("%s %s" % (t, ("p. %d" % round(it["to"]) if it["kind"] == "pages" else hm(it["to"]))) for t, it in pos.items() if it["to"] < next(b["units"] for b in r["books"] if b["title"] == t) - 1e-6) or "all current books done"))
return out
def report(r, m, mode):
hi = [f for f in r["findings"] if f[0] == "high"]
out = ["# %s" % m["name"], ""]
tot_min = sum(x["planned"] for x in r["days"])
out.append("Start %s, %s mode, %d buffer day(s) before each deadline, catch-up days: %s. Planned reading: %d h %02d min over %d days." % (
m["start"], mode, m["buffer"], ", ".join(sorted(m["catch"], key=DAYS.index)) or "none", tot_min // 60, tot_min % 60, len(r["days"])))
out += ["", "**Verdict: %s**" % ("%d book(s) cannot be finished by their deadline - change the plan" % len(hi) if hi else "every deadline is met"), ""]
out += ["## Books", "", "| Book | Left | Est. time | Deadline | Finish by | Planned finish | Days spare | Avg per reading day |", "|---|---|---|---|---|---|---|---|"]
for b in r["all_books"]:
left = b["units"] - b["done"]
rd = [x for x in r["days"] if any(it["title"] == b["title"] for it in x["items"])]
avg = left / len(rd) if rd else 0
spare = (b["deadline"] - b["finish"]).days if b.get("finish") and b["deadline"] else None
out.append("| %s | %s | %s | %s | %s | %s | %s | %s |" % (
b["title"], ("%d pp" % round(left)) if b["kind"] == "pages" else hm(left) + " audio", hm(b["need"]) + " h",
"%s %s" % (b["deadline"], "(%s)" % b["event"] if b["event"] else "") if b["deadline"] else "-",
b["target"] or "-", b.get("finish") or ("done" if b["need"] <= 0 else "NOT IN TIME"),
"-" if spare is None else spare, ("%.0f pp" % avg) if b["kind"] == "pages" else hm(avg)))
out += ["", "## Findings", ""]
out += ["- **%s %s** %s" % (s.upper(), c, msg) for s, c, msg in r["findings"]] or ["- none"]
out += ["", "## Day by day", "", "| Date | Day | Minutes | Read | Note |", "|---|---|---|---|---|"]
events = {}
for b in r["all_books"]:
if b["deadline"]:
events.setdefault(b["deadline"], []).append("DEADLINE %s%s" % (b["title"], " - " + b["event"] if b["event"] else ""))
if b["target"] and b["target"] != b["deadline"]:
events.setdefault(b["target"], []).append("finish-by %s" % b["title"])
for x in r["days"]:
note = [x["note"]] if x["note"] else []
if x["note"] == "catch-up day" or (x["planned"] < 0.5 and x["avail"] > 0):
note = ["catch-up / spare (%d min available)" % x["avail"]] if x["avail"] > 0 else note
note += events.get(x["date"], [])
if m["max_session"] and x["planned"] > m["max_session"] + 0.5:
note.append("2 sessions")
out.append("| %s | %s | %s | %s | %s |" % (x["date"], x["date"].strftime("%a"), round(x["planned"]) if x["planned"] >= 0.5 else "-",
"; ".join(fmt_item(it) for it in x["items"]) or "-", "; ".join(note)))
cps = checkpoints(r)
if cps:
out += ["", "## Weekly checkpoints (where you should be by Sunday night)", ""]
out += ["- %s: %s" % (d, s) for d, s in cps]
out += ["", "## If you fall behind", ""]
for b in r["books"]:
if b["kind"] != "pages" or not b["deadline"]:
continue
rd = [x for x in r["days"] if any(it["title"] == b["title"] for it in x["items"])]
if not rd:
continue
day_pp = sum(it["to"] - it["from"] for x in rd for it in x["items"] if it["title"] == b["title"]) / len(rd)
spare = [x for x in r["days"] if x["date"] <= b["deadline"] and x["date"] >= rd[0]["date"] and x["avail"] > 0 and x["planned"] < 0.5]
out.append("- %s: a missed day costs about %d pages. Make them up on %s, or add %d pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at \"%s=<page>\"`." % (
b["title"], round(day_pp), ("the spare day(s) " + ", ".join(str(x["date"]) for x in spare[:3])) if spare else "no spare day before the deadline", math.ceil(day_pp / 3), b["title"]))
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def run(cfg, mode="even", **kw):
m = load(cfg, **kw)
return plan(m, mode), m
base = {"start": "2026-11-02", "reader": {"words_per_minute": 250, "words_per_page": 250}, "minutes": 30, "buffer_days": 0}
r, m = run(dict(base, books=[{"title": "A", "pages": 60, "deadline": "2026-11-08"}]), "asap")
t("asap: 30 pages/day at 1 page/min finishes day 2", r["books"][0]["finish"] == datetime.date(2026, 11, 3))
r, m = run(dict(base, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]))
t("even: spread over the week (~10 min/day + 3% headroom)", r["books"][0]["finish"] >= datetime.date(2026, 11, 7) and 10 <= r["days"][0]["planned"] <= 10.31)
r, m = run(dict(base, books=[{"title": "A", "pages": 300, "deadline": "2026-11-08"}]))
t("infeasible -> RP01 high", any(f[1] == "RP01" and f[0] == "high" for f in r["findings"]))
t("RP01 suggests extra minutes", any("+13 min" in f[2] for f in r["findings"] if f[1] == "RP01"))
r, m = run(dict(base, buffer_days=2, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]))
t("buffer days: finish 2 days early", r["books"][0]["finish"] == datetime.date(2026, 11, 6))
r, m = run(dict(base, catch_up_days=["sun"], books=[{"title": "A", "pages": 60, "deadline": "2026-11-08"}]))
t("catch-up Sunday gets no planned reading", r["days"][-1]["planned"] == 0 and r["books"][0]["finish"] == datetime.date(2026, 11, 7))
r, m = run(dict(base, exceptions=[{"dates": "2026-11-03..2026-11-04", "minutes": 0, "note": "trip"}], books=[{"title": "A", "pages": 50, "deadline": "2026-11-08"}]))
t("exception range has no reading", all(x["planned"] == 0 for x in r["days"][1:3]))
two = [{"title": "Late", "pages": 30, "deadline": "2026-11-20"}, {"title": "Soon", "pages": 60, "deadline": "2026-11-04"}]
r, m = run(dict(base, books=two))
t("earliest deadline first", r["days"][0]["items"][0]["title"] == "Soon")
t("tight segment then lighter load", r["load"][0] > 0.6 and r["load"][5] < 0.2)
r, m = run(dict(base, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]), at={"A": 36})
t("--at resumes from page", abs(sum(x["planned"] for x in r["days"]) - 35) < 0.01)
r, m = run(dict(base, books=[{"title": "W", "words": 25000, "deadline": "2026-11-08"}]))
t("word count -> pages via words_per_page", r["all_books"][0]["units"] == 100)
r, m = run(dict(base, books=[{"title": "H", "pages": 70, "difficulty": 1.5, "deadline": "2026-11-08"}]))
t("difficulty scales time", abs(r["books"][0]["need"] - 105) < 0.01)
r, m = run(dict(base, books=[{"title": "Au", "audio_minutes": 300, "speed": 1.5, "deadline": "2026-11-08"}]))
t("audiobook at 1.5x = 200 min", abs(r["books"][0]["need"] - 200) < 0.01 and any(f[1] == "RP06" for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "C", "pages": 70, "deadline": "2026-11-08", "chapters": [1, 12, 23, 31, 42, 55]}]))
ends = [round(it["to"]) for x in r["days"] for it in x["items"]]
t("chapter snapping stops at chapter ends", 11 in ends and 22 in ends and ends[-1] == 70)
t("one item per book per day", all(len({it["title"] for it in x["items"]}) == len(x["items"]) for x in r["days"]))
t("page ranges do not overlap", all(fmt_item(it) for x in r["days"] for it in x["items"]) and
[int(round(it["from"])) for x in r["days"] for it in x["items"]][1:] == [int(round(it["to"])) for x in r["days"] for it in x["items"]][:-1])
r, m = run(dict(base, max_session_minutes=20, minutes=60, books=[{"title": "A", "pages": 300, "deadline": "2026-11-08"}]))
t("long sessions -> RP03", any(f[1] == "RP03" for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "A", "pages": 200, "deadline": "2026-11-08"}]))
t("tight plan -> RP04 or RP01", any(f[1] in ("RP04", "RP01") for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "A", "pages": 30}, {"title": "B", "pages": 60, "deadline": "2026-11-05"}]))
t("undated book after dated ones", r["books"][-1]["title"] == "A" and r["books"][-1]["finish"])
r, m = run(dict(base, books=[{"title": "A", "pages": 20, "start_page": 21}]))
t("finished book -> RP05", any(f[1] == "RP05" for f in r["findings"]))
t("pages_per_hour", abs(load(dict(base, reader={"pages_per_hour": 30}, books=[{"title": "P", "pages": 30}]))["books"][0]["unit_min"] - 2) < 1e-9)
for bad in [dict(base, books=[]), dict(base, books=[{"title": "x"}]), dict(base, minutes={"funday": 3}, books=[{"title": "a", "pages": 3}]),
dict(base, books=[{"title": "a", "pages": 3, "deadline": "2026-13-01"}])]:
try:
run(bad); okk = False
except InputError:
okk = True
t("bad input rejected: %s" % (list(bad.get("minutes", {})) if isinstance(bad.get("minutes"), dict) else (bad["books"][0] if bad["books"] else "no books")), okk)
r, m = run(dict(base, books=[{"title": "Old", "pages": 30, "deadline": "2026-10-01"}]))
t("passed deadline -> RP05, planned without deadline", any(f[1] == "RP05" for f in r["findings"]) and r["books"][0]["finish"])
rep = report(*run(dict(base, books=two)), "even")
t("report has verdict, day table and catch-up advice", "Verdict" in rep and "| Date |" in rep and "If you fall behind" in rep)
for name, okk in tests:
print(("PASS " if okk else "FAIL ") + name)
n = sum(1 for _, okk in tests if okk)
print("%d/%d passed" % (n, len(tests)))
return 0 if n == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("plan", nargs="?")
ap.add_argument("--mode", choices=["even", "asap"], default="even")
ap.add_argument("--from", dest="start", help="re-plan from this date (YYYY-MM-DD)")
ap.add_argument("--at", action="append", default=[], help='"Title=page" (or "Title=minute" for audiobooks): where you are now')
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.plan:
ap.error("plan JSON required (or --self-test)")
try:
try:
cfg = json.load(open(a.plan, encoding="utf-8"))
except (OSError, ValueError) as e:
raise InputError(str(e))
at = {}
for s in a.at:
if "=" not in s:
raise InputError("--at needs Title=page, got %r" % s)
k, v = s.rsplit("=", 1)
try:
at[k.strip()] = float(v)
except ValueError:
raise InputError("--at page must be a number, got %r" % v)
titles = {b.get("title") for b in cfg.get("books", [])} if isinstance(cfg, dict) else set()
for k in at:
if k not in titles:
raise InputError("--at: no book titled %r" % k)
m = load(cfg, a.start, at)
r = plan(m, a.mode)
except (InputError, KeyError, TypeError, ValueError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
out = {"books": [{"title": b["title"], "deadline": str(b["deadline"]) if b["deadline"] else None, "finish": str(b.get("finish")) if b.get("finish") else None,
"minutes_needed": round(b["need"])} for b in r["all_books"]],
"days": [{"date": str(x["date"]), "minutes": round(x["planned"]), "read": [fmt_item(it) for it in x["items"]]} for x in r["days"]],
"findings": [{"severity": s, "code": c, "message": msg} for s, c, msg in r["findings"]]}
print(json.dumps(out, indent=2))
else:
print(report(r, m, a.mode))
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Checks invoices before you pay or send them - recomputes every line (quantity x unit price minus discount) and the VAT or sales tax per line, the tax breakdown and the totals from a JSON or CSV export. Reports arithmetic errors, rates outside your list, per-line vs per-invoice rounding, discounts, duplicate lines and invoices, missing required fields and date problems. Includes an offline stdlib Python checker. Not tax advice.
---
name: invoice-line-item-and-vat-total-checker
description: Checks invoices before you pay or send them - recomputes every line (quantity x unit price minus discount) and the VAT or sales tax per line, the tax breakdown and the totals from a JSON or CSV export. Reports arithmetic errors, rates outside your list, per-line vs per-invoice rounding, discounts, duplicate lines and invoices, missing required fields and date problems. Includes an offline stdlib Python checker. Not tax advice.
---
# Invoice Line Item and VAT Total Checker
You help small businesses, freelancers and finance assistants catch invoice mistakes before money moves: a line total that does not equal quantity x price, a tax amount worked out on the wrong base, a rate that is not one you use, a delivery line taxed at a rate from another country, the same invoice entered twice, a credit note with no VAT ID. You recompute everything from the lines, compare with what the invoice says, and hand back a discrepancy report someone can act on.
You check arithmetic and consistency against the rules the user gives you. You do not decide which tax rate legally applies, whether tax is due or which fields the law requires; say so in every report and point to an accountant or the tax authority for those questions.
## Files in this skill
- `scripts/invoice_check.py` - loads an invoices JSON (header, lines, stated totals, optional tax breakdown) or a flat line-items CSV (one row per line, grouped by invoice number and seller); recomputes line net, tax and gross with Decimal arithmetic, per-line or per-invoice tax rounding, half-up or half-even, tax-exclusive or tax-inclusive prices, line and invoice discounts; reports findings IV01-IV13 and a per-invoice line table (Python 3.8+ standard library only)
- `references/checks-and-codes.md` - every finding code, its severity and what to do about it
- `references/tax-math-and-rounding.md` - the formulas, rounding methods, discounts, credit notes and tolerance
- `templates/discrepancy-report.md` - the report to send to the supplier, client or bookkeeper
- `examples/invoices.json` - five purchase invoices of a design studio: one clean, one with a wrong line, a non-listed rate, a duplicated line and a category rate mismatch, a cloud bill with fractional quantities and a due date before the invoice date, a duplicate entry and a credit note without a VAT ID
- `examples/vat-rules.json` - example rules: allowed rates 20/10/0, a category -> rate list, per-line rounding, required fields (replace the rates with yours)
- `examples/invoice-lines.csv` - two of the studio's own sales invoices as a line-items export, one with a tax error, a line left out of the totals, a missing buyer and a currency that changes mid-invoice
- `examples/invoice-report.md` - the real checker output on invoices.json
## Workflow
### 1. Collect the invoices and the rules
Ask for the invoices as an export (accounting tool CSV, e-invoice JSON) or paste the lines; one file can hold a whole batch. Then ask for the rules: the tax rates the business actually uses, any item categories with their own rate (from their accountant, not from you), whether prices are tax-exclusive or tax-inclusive, how the tool rounds tax (per line or per invoice), the currency decimals and which header fields their invoices must carry. Write them into a rules JSON like `examples/vat-rules.json`.
### 2. Run the checker
```
python3 scripts/invoice_check.py examples/invoices.json --rules examples/vat-rules.json --today 2026-11-03
python3 scripts/invoice_check.py examples/invoice-lines.csv --rules examples/vat-rules.json
python3 scripts/invoice_check.py batch.json --rules rules.json --json
python3 scripts/invoice_check.py --self-test
```
Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, recompute by hand with `references/tax-math-and-rounding.md`: each line net = round(qty x unit price x (1 - discount)), tax = round(net x rate), then sum per rate and compare with the stated totals.
### 3. Read the result
- High first: IV02/IV03 (a line is wrong), IV05 (totals do not add up), IV04 (a rate not in your list), IV07 (same invoice number twice), IV01 (no number, date, quantity or price). Do not pay or send these.
- Medium: category rate mismatches, duplicate lines, negative lines on a normal invoice, due dates before the invoice date, missing VAT IDs or buyer names, a header that changes between CSV rows.
- Low/info: rounding-method differences (IV06) and extra decimals (IV12) - usually explainable, note them.
- A wrong total often comes from one wrong line: fix the line, then re-run before reporting the total.
### 4. Decide and write it up
For each invoice: OK to pay/send, ask for a corrected invoice or credit note, or check internally (duplicate entry, wrong rule). Fill `templates/discrepancy-report.md` with the line, the stated and recomputed amounts and the exact correction you need. Keep the tone factual: "line 1: 250 x 0.38 = 95.00, invoiced 98.00".
### 5. Deliver
Return the per-invoice verdicts, the discrepancy report(s), a list of questions for the accountant (rates, required fields) and any rule changes for next time (e.g. switch to per-invoice rounding if that is what the supplier's system does).
## Example result
On `examples/invoices.json` 2 of 5 invoices must not be paid as they are. Brightline Print 2026/0877 bills 250 business cards at 0.38 as 98.00 instead of 95.00 (IV02), taxes delivery at 18%, which is not in the studio's rates (IV04), repeats the flyer line (IV07) and taxes catering at 20% where the studio's list says 10% (IV04, medium); its totals are 3.00 net / 0.60 tax too high (IV05). Northwind's INV-2026-1041 was entered twice (IV07, high). CloudNest's tax of 7.38 is per-invoice rounding instead of 7.39 per line (IV06 info, within tolerance) but its due date is before the invoice date; the Brightline credit note has no seller VAT ID. The clean Northwind invoice passes.
## Rules
- Recompute from the lines; never trust a stated total, and report the first wrong line before the totals it breaks.
- Use only the rates and categories the user gives you. If asked which rate applies, say that is a tax question for their accountant or tax authority.
- Use exact decimal arithmetic and the user's rounding method; a 0.01 rounding difference is not an error unless their rules say so.
- Treat duplicates as "possible": show both invoices and let the user confirm.
- Never change an invoice yourself; ask the issuer for a corrected invoice or a credit note.
- Keep invoice data private: do not paste it into public tools or share it beyond the people who need it.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | Typical fix |
|---|---|---|---|
| IV01 | high: number, date, qty, price, no lines, no totals; medium: other fields | required invoice and line fields present and parseable (fields come from `required_invoice_fields` / `required_line_fields`) | ask for a corrected invoice with the missing field |
| IV02 | high | line net = round(qty x unit price x (1 - discount%)); line gross = net + tax | corrected invoice for that line |
| IV03 | high | line tax = round(line net x rate); a tax that is consistent with an already wrong net is not reported twice | corrected invoice |
| IV04 | high: rate not in `allowed_vat_rates`; medium: rate differs from your `category_rates`; low: rate written as a fraction (0.2) | rate sanity against YOUR list | check the rate with your accountant, then ask the issuer |
| IV05 | high (medium if within tolerance x lines) | stated net, tax and grand total vs the recomputed sums; grand total = net + tax | corrected invoice; often follows from IV02/IV03 |
| IV06 | low (info if within tolerance) | tax total matches the other rounding method (per invoice vs per line) | accept, or set `vat_rounding` to what the issuer uses |
| IV07 | high: same seller + invoice number twice; medium: same seller, date and total under another number, or a repeated line | duplicates | confirm with the issuer before paying twice |
| IV08 | high: discount outside 0-100%; info: invoice-level discount applied | discounts reduce the taxable base before tax | corrected invoice |
| IV09 | medium: negative qty/price on a normal invoice; low: zero qty/price | returns belong on a credit note (`"type": "credit_note"`) | ask for a credit note |
| IV10 | high: invalid invoice date; medium: invalid or earlier due date, invoice date in the future | dates | corrected invoice / check the entry |
| IV11 | medium | stated tax breakdown per rate vs the lines | corrected invoice |
| IV12 | low | stated amounts with more decimals than `currency_decimals` (unit prices may have more) | usually cosmetic |
| IV13 | medium | CSV export: header values (currency, buyer, date...) differ between rows of one invoice | fix the export or the source record |
## Verdicts
- **FIX**: at least one high finding. Do not pay or send.
- **CHECK**: medium findings only. Usually a question for the issuer or an internal check.
- **OK**: nothing, or only low/info.
## Input fields
JSON invoice: `number, date, due_date, type (invoice|credit_note), seller{name, vat_id}, buyer{name, vat_id}, currency, invoice_discount_pct, lines[], totals{net, vat, gross}, vat_breakdown[{rate, net, vat}]`.
JSON line: `sku, description, category, qty, unit_price, discount_pct, vat_rate, net, vat, gross` (stated amounts are optional - lines without them are only recomputed).
CSV columns: `invoice_number, invoice_date, due_date, type, seller_name, seller_vat_id, buyer_name, buyer_vat_id, currency, invoice_discount_pct, sku, description, category, qty, unit_price, discount_pct, vat_rate, line_net, vat_amount, line_gross, total_net, total_vat, total_gross` (totals on any row of the invoice). Decimal commas (12,50 and 1.234,56) are accepted.
FILE:references/tax-math-and-rounding.md
# Tax math and rounding
This is arithmetic, not tax law. Rates, exemptions, reverse charge and required invoice fields depend on the country and the transaction: take them from the user's accountant or tax authority.
## Tax-exclusive prices (default)
```
line net = round(qty x unit_price x (1 - discount_pct / 100))
line tax = round(line net x rate / 100) per-line rounding
line gross = line net + line tax
net total = sum(line net) (x (1 - invoice_discount_pct / 100) per rate, rounded)
tax total = sum(line tax) per-line rounding
= sum over rates of round(net of that rate x rate / 100) per-invoice rounding
grand total = net total + tax total
```
## Tax-inclusive prices (`prices_include_vat: true`)
```
line gross = round(qty x unit_price x (1 - discount_pct / 100))
line tax = round(line gross x rate / (100 + rate))
line net = line gross - line tax
```
Example: gross 12.00 at 20% -> tax 2.00, net 10.00.
## Rounding
- `half_up` (default): 0.005 -> 0.01. Most invoicing tools and many tax authorities use it.
- `half_even` (banker's rounding): 0.005 -> 0.00, 0.015 -> 0.02. Some ERP and spreadsheet functions use it.
- Per-line vs per-invoice rounding can differ by up to 0.005 x number of lines. Ten lines of 0.05 at 10%: per line 10 x 0.01 = 0.10, per invoice round(0.50 x 10%) = 0.05. Both can be legitimate; check which method the issuer uses and set `vat_rounding`.
- `tolerance` (default 0.01) is the difference accepted per amount; differences at or below it are not errors (IV06 info shows when they come from the rounding method).
## Discounts
- Line discounts reduce the line net before tax.
- An invoice-level discount (`invoice_discount_pct`) reduces the taxable base of every rate proportionally before tax; the checker applies it per rate.
- Early-payment (settlement) discounts that are only taken later are not modelled; check them separately.
## Credit notes and negative lines
- A credit note (`"type": "credit_note"`) has negative quantities or prices and negative totals; tax is negative too.
- A negative line on a normal invoice (a return netted off) is flagged (IV09) because many systems and auditors expect a separate credit note.
## Multi-currency
- All amounts of one invoice must be in one currency; a currency that changes between CSV rows is flagged (IV13).
- Conversion to your book currency is out of scope: convert after the check with the rate your bookkeeping uses.
FILE:templates/discrepancy-report.md
# Invoice discrepancy report
**To:** <supplier / client / bookkeeper> **From:** <name> **Date:** <YYYY-MM-DD>
**Invoice:** <number> dated <date>, total <stated total> <currency>
We checked this invoice line by line before <paying / sending> it and found the following. Please send a corrected invoice or a credit note for the difference.
| # | Line / field | Invoiced | Should be | Difference | Note |
|---|---|---|---|---|---|
| 1 | <line 1: 250 x 0.38> | <98.00> | <95.00> | <3.00> | <quantity x unit price> |
| 2 | <tax rate on delivery> | <18%> | <rate we expect, confirmed by our accountant> | <...> | <...> |
**Totals**
| | Invoiced | Recomputed |
|---|---|---|
| Net | <...> | <...> |
| Tax | <...> | <...> |
| Total | <...> | <...> |
**Other points**
- <duplicate line / missing VAT ID / due date before invoice date>
**What we need:** <corrected invoice | credit note for X | confirmation that invoice Y is a duplicate>
*Recomputed from the invoice lines with <rounding> rounding, tolerance <0.01>. This is an arithmetic check, not tax advice.*
FILE:examples/invoices.json
{
"invoices": [
{
"number": "INV-2026-1041",
"date": "2026-10-05",
"due_date": "2026-11-04",
"seller": {"name": "Northwind Office Supplies", "vat_id": "GB123456789"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"sku": "PAP-A4-5", "description": "A4 copy paper, box of 5 reams", "qty": 4, "unit_price": "21.50", "vat_rate": 20, "net": "86.00", "vat": "17.20", "gross": "103.20"},
{"sku": "TON-HP-26X", "description": "Toner cartridge 26X", "qty": 2, "unit_price": "89.99", "discount_pct": 5, "vat_rate": 20, "net": "170.98", "vat": "34.20", "gross": "205.18"},
{"sku": "BK-TYPO", "description": "Book: Thinking with Type", "category": "books", "qty": 1, "unit_price": "24.00", "vat_rate": 0, "net": "24.00", "vat": "0.00", "gross": "24.00"}
],
"totals": {"net": "280.98", "vat": "51.40", "gross": "332.38"},
"vat_breakdown": [{"rate": 20, "net": "256.98", "vat": "51.40"}, {"rate": 0, "net": "24.00", "vat": "0.00"}]
},
{
"number": "2026/0877",
"date": "2026-10-12",
"due_date": "2026-10-26",
"seller": {"name": "Brightline Print", "vat_id": "GB987654321"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"sku": "BC-350", "description": "Business cards, 350gsm", "qty": 250, "unit_price": "0.38", "vat_rate": 20, "net": "98.00", "vat": "19.60", "gross": "117.60"},
{"sku": "FLY-A5", "description": "A5 flyers, double-sided", "qty": 1000, "unit_price": "0.12", "discount_pct": 10, "vat_rate": 20, "net": "108.00", "vat": "21.60", "gross": "129.60"},
{"sku": "FLY-A5", "description": "A5 flyers, double-sided", "qty": 1000, "unit_price": "0.12", "discount_pct": 10, "vat_rate": 20, "net": "108.00", "vat": "21.60", "gross": "129.60"},
{"sku": "DEL-STD", "description": "Delivery", "qty": 1, "unit_price": "12.50", "vat_rate": 18, "net": "12.50", "vat": "2.25", "gross": "14.75"},
{"sku": "CAT-01", "description": "Catering for launch event", "category": "catering", "qty": 1, "unit_price": "150.00", "vat_rate": 20, "net": "150.00", "vat": "30.00", "gross": "180.00"}
],
"totals": {"net": "476.50", "vat": "95.05", "gross": "571.55"}
},
{
"number": "CN-8841",
"date": "2026-10-31",
"due_date": "2026-10-01",
"seller": {"name": "CloudNest Hosting", "vat_id": "IE6388047V"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"description": "Object storage, GB-month", "qty": "412.5", "unit_price": "0.0185", "vat_rate": 20},
{"description": "Egress, GB", "qty": "96.3", "unit_price": "0.0725", "vat_rate": 20},
{"description": "Load balancer hours", "qty": 744, "unit_price": "0.0225", "vat_rate": 20},
{"description": "Snapshot storage, GB-month", "qty": "38.4", "unit_price": "0.0420", "vat_rate": 20},
{"description": "DNS queries, per million", "qty": "1.7", "unit_price": "0.35", "vat_rate": 20},
{"description": "Static IP hours", "qty": 744, "unit_price": "0.0045", "vat_rate": 20}
],
"totals": {"net": "36.91", "vat": "7.38", "gross": "44.29"}
},
{
"number": "INV-2026-1041",
"date": "2026-10-05",
"seller": {"name": "Northwind Office Supplies", "vat_id": "GB123456789"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"sku": "PAP-A4-5", "description": "A4 copy paper, box of 5 reams", "qty": 4, "unit_price": "21.50", "vat_rate": 20},
{"sku": "TON-HP-26X", "description": "Toner cartridge 26X", "qty": 2, "unit_price": "89.99", "discount_pct": 5, "vat_rate": 20},
{"sku": "BK-TYPO", "description": "Book: Thinking with Type", "category": "books", "qty": 1, "unit_price": "24.00", "vat_rate": 0}
],
"totals": {"net": "280.98", "vat": "51.40", "gross": "332.38"}
},
{
"number": "CR-0012",
"type": "credit_note",
"date": "2026-10-20",
"seller": {"name": "Brightline Print"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"sku": "BC-350", "description": "Credit: misprinted business cards", "qty": -50, "unit_price": "0.38", "vat_rate": 20, "net": "-19.00", "vat": "-3.80", "gross": "-22.80"}
],
"totals": {"net": "-19.00", "vat": "-3.80", "gross": "-22.80"}
}
]
}
FILE:examples/vat-rules.json
{
"name": "Example rules for a studio's purchase invoices - replace the rates with the ones that apply to you",
"currency_decimals": 2,
"rounding": "half_up",
"vat_rounding": "line",
"tolerance": "0.01",
"prices_include_vat": false,
"allowed_vat_rates": [20, 10, 0],
"category_rates": {"books": 0, "catering": 10, "food": 10},
"required_invoice_fields": ["number", "date", "seller.name", "seller.vat_id", "buyer.name", "currency"],
"required_line_fields": ["description", "qty", "unit_price", "vat_rate"],
"max_discount_pct": 100
}
FILE:examples/invoice-lines.csv
invoice_number,invoice_date,due_date,seller_name,seller_vat_id,buyer_name,currency,sku,description,category,qty,unit_price,discount_pct,vat_rate,line_net,vat_amount,line_gross,total_net,total_vat,total_gross
S-1001,2026-10-28,2026-11-27,Lumen Studio Ltd,GB555000111,Harbour Cafe,GBP,LOGO,"Logo design, 3 concepts",,1,850.00,,20,850.00,170.00,1020.00,1170.00,234.00,1404.00
S-1001,2026-10-28,2026-11-27,Lumen Studio Ltd,GB555000111,Harbour Cafe,GBP,MENU,Menu layout (A4 folded),,2,160.00,,20,320.00,64.00,384.00,,,
S-1002,2026-10-30,2026-11-29,Lumen Studio Ltd,GB555000111,,GBP,WEB-DAY,Website build - day rate,,4.5,480.00,10,20,1944.00,388.80,2332.80,2104.00,420.80,2524.80
S-1002,2026-10-30,2026-11-29,Lumen Studio Ltd,GB555000111,,GBP,PHOTO,"Stock photo licence, 5 images",,5,32.00,,20,160.00,23.00,183.00,,,
S-1002,2026-10-30,2026-11-29,Lumen Studio Ltd,GB555000111,,EUR,BOOK,Brand guidelines book (printed),books,1,45.00,,0,45.00,0.00,45.00,,,
FILE:examples/invoice-report.md
# Invoice check: examples/invoices.json
5 invoice(s), 18 line(s). Rules: half up rounding, tax rounded per line, tolerance 0.01, prices exclude tax, allowed rates 20/10/0%.
**Verdict: 2 of 5 invoice(s) have high findings - do not pay/send until fixed**
Findings: 5 high, 4 medium, 0 low, 1 info.
This is an arithmetic and consistency check against your own rules, not tax advice: confirm rates and required invoice fields with your accountant or tax authority.
## Invoices
| Invoice | Seller | Lines | Stated total | Recomputed | Difference | High | Medium | Low | Status |
|---|---|---|---|---|---|---|---|---|---|
| INV-2026-1041 | Northwind Office Supplies | 3 | 332.38 GBP | 332.38 | 0.00 | 0 | 0 | 0 | OK |
| 2026/0877 | Brightline Print | 5 | 571.55 GBP | 567.95 | 3.60 | 4 | 2 | 0 | FIX |
| CN-8841 | CloudNest Hosting | 6 | 44.29 GBP | 44.30 | -0.01 | 0 | 1 | 0 | CHECK |
| INV-2026-1041 (#4) | Northwind Office Supplies | 3 | 332.38 GBP | 332.38 | 0.00 | 1 | 0 | 0 | FIX |
| CR-0012 (credit note) | Brightline Print | 1 | -22.80 GBP | -22.80 | 0.00 | 0 | 1 | 0 | CHECK |
## 2026/0877 - Brightline Print
| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |
|---|---|---|---|---|---|---|---|---|
| 1 | Business cards, 350gsm | 250 | 0.38 | | 20 | 98.00 / 95.00 | 19.60 / 19.00 | net |
| 2 | A5 flyers, double-sided | 1000 | 0.12 | 10 | 20 | 108.00 / 108.00 | 21.60 / 21.60 | ok |
| 3 | A5 flyers, double-sided | 1000 | 0.12 | 10 | 20 | 108.00 / 108.00 | 21.60 / 21.60 | duplicate |
| 4 | Delivery | 1 | 12.50 | | 18 | 12.50 / 12.50 | 2.25 / 2.25 | rate |
| 5 | Catering for launch event | 1 | 150.00 | | 20 | 150.00 / 150.00 | 30.00 / 30.00 | category rate |
| Totals | Stated | Recomputed |
|---|---|---|
| Net | 476.50 | 473.50 |
| Tax | 95.05 | 94.45 |
| Total | 571.55 | 567.95 |
| Tax by rate | | 20%: 92.20 on 461.00; 18%: 2.25 on 12.50 |
- **HIGH IV02** line 1 (Business cards, 350gsm): line net 98.00 but 250 x 0.38 = 95.00 (diff 3.00)
- **HIGH IV04** line 4 (Delivery): tax rate 18% is not in your allowed rates (0, 10, 20)
- **HIGH IV05** stated net total 476.50 but the lines add up to 473.50 (diff 3.00)
- **HIGH IV05** stated tax total 95.05 but the lines add up to 94.45 (diff 0.60)
- **MEDIUM IV04** line 5 (Catering for launch event): category 'catering' is 10% in your rate list but the line uses 20%
- **MEDIUM IV07** line 3 (A5 flyers, double-sided) repeats line 2 (same item, quantity and price) - duplicate?
## CN-8841 - CloudNest Hosting
| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |
|---|---|---|---|---|---|---|---|---|
| 1 | Object storage, GB-month | 412.5 | 0.0185 | | 20 | - / 7.63 | - / 1.53 | ok |
| 2 | Egress, GB | 96.3 | 0.0725 | | 20 | - / 6.98 | - / 1.40 | ok |
| 3 | Load balancer hours | 744 | 0.0225 | | 20 | - / 16.74 | - / 3.35 | ok |
| 4 | Snapshot storage, GB-month | 38.4 | 0.0420 | | 20 | - / 1.61 | - / 0.32 | ok |
| 5 | DNS queries, per million | 1.7 | 0.35 | | 20 | - / 0.60 | - / 0.12 | ok |
| 6 | Static IP hours | 744 | 0.0045 | | 20 | - / 3.35 | - / 0.67 | ok |
| Totals | Stated | Recomputed |
|---|---|---|
| Net | 36.91 | 36.91 |
| Tax | 7.38 | 7.39 |
| Total | 44.29 | 44.30 |
- **MEDIUM IV10** due date 2026-10-01 is before the invoice date 2026-10-31
- **INFO IV06** tax total 7.38 is per-invoice rounding (line rounding gives 7.39) - within tolerance
## INV-2026-1041 (#4) - Northwind Office Supplies
| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |
|---|---|---|---|---|---|---|---|---|
| 1 | A4 copy paper, box of 5 reams | 4 | 21.50 | | 20 | - / 86.00 | - / 17.20 | ok |
| 2 | Toner cartridge 26X | 2 | 89.99 | 5 | 20 | - / 170.98 | - / 34.20 | ok |
| 3 | Book: Thinking with Type | 1 | 24.00 | | 0 | - / 24.00 | - / 0.00 | ok |
| Totals | Stated | Recomputed |
|---|---|---|
| Net | 280.98 | 280.98 |
| Tax | 51.40 | 51.40 |
| Total | 332.38 | 332.38 |
| Tax by rate | | 20%: 51.40 on 256.98; 0%: 0.00 on 24.00 |
- **HIGH IV07** invoice number INV-2026-1041 from this seller already appears (invoice #1) - duplicate invoice?
## CR-0012 - Brightline Print
| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |
|---|---|---|---|---|---|---|---|---|
| 1 | Credit: misprinted business cards | -50 | 0.38 | | 20 | -19.00 / -19.00 | -3.80 / -3.80 | ok |
| Totals | Stated | Recomputed |
|---|---|---|
| Net | -19.00 | -19.00 |
| Tax | -3.80 | -3.80 |
| Total | -22.80 | -22.80 |
- **MEDIUM IV01** missing invoice field 'seller.vat_id'
FILE:scripts/invoice_check.py
#!/usr/bin/env python3
"""Invoice line item and VAT / sales-tax total checker (stdlib only, Python 3.8+).
Recomputes every line (qty x unit price - discount, tax per line) and every invoice total from
an invoices JSON or a flat line-items CSV, compares them with the stated amounts and reports
arithmetic errors, tax rates outside your list, rounding-method differences, discounts,
duplicate lines and invoices, missing required fields and date problems.
It checks numbers against the rules YOU give it; it does not decide which tax rate applies.
Usage:
invoice_check.py INVOICES.json|LINES.csv [--rules RULES.json] [--today YYYY-MM-DD] [--json]
invoice_check.py --self-test
Exit: 0 = no high findings, 1 = high findings (wrong totals, wrong rate, missing number/date,
duplicate invoice), 2 = bad input.
"""
import argparse, csv, datetime, io, json, re, sys
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN, InvalidOperation
DEFAULT_RULES = {
"currency_decimals": 2,
"rounding": "half_up", # half_up | half_even
"vat_rounding": "line", # line = round tax per line then sum; invoice = per rate on the total
"tolerance": "0.01", # accepted difference per amount
"prices_include_vat": False, # unit prices are gross (tax-inclusive)
"allowed_vat_rates": None, # e.g. [20, 10, 1, 0]; None = do not check
"category_rates": {}, # your own mapping, e.g. {"books": 0}
"required_invoice_fields": ["number", "date", "seller.name", "seller.vat_id", "buyer.name", "currency"],
"required_line_fields": ["description", "qty", "unit_price", "vat_rate"],
"max_discount_pct": 100,
}
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CSV_MAP = {"invoice_number": "number", "invoice_date": "date", "due_date": "due_date", "type": "type",
"seller_name": "seller.name", "seller_vat_id": "seller.vat_id", "buyer_name": "buyer.name",
"buyer_vat_id": "buyer.vat_id", "currency": "currency", "invoice_discount_pct": "invoice_discount_pct",
"total_net": "totals.net", "total_vat": "totals.vat", "total_gross": "totals.gross"}
LINE_MAP = {"sku": "sku", "description": "description", "category": "category", "qty": "qty",
"unit_price": "unit_price", "discount_pct": "discount_pct", "line_net": "net", "vat_rate": "vat_rate",
"vat_amount": "vat", "line_gross": "gross"}
class InputError(Exception):
pass
def D(v, what="number"):
if v is None or (isinstance(v, str) and not v.strip()):
return None
if isinstance(v, float):
v = repr(v)
try:
s = str(v).strip().replace(" ", "")
if re.match(r"^-?\d{1,3}(\.\d{3})+,\d+$", s): # 1.234,56
s = s.replace(".", "").replace(",", ".")
elif re.match(r"^-?\d+,\d+$", s): # 12,50
s = s.replace(",", ".")
else:
s = s.replace(",", "") # 1,234.56
s = s.rstrip("%")
return Decimal(s)
except (InvalidOperation, ValueError):
raise InputError("not a %s: %r" % (what, v))
def get(d, path):
for k in path.split("."):
if not isinstance(d, dict):
return None
d = d.get(k)
return d
def setp(d, path, v):
ks = path.split(".")
for k in ks[:-1]:
d = d.setdefault(k, {})
d[ks[-1]] = v
def blank(v):
return v is None or (isinstance(v, str) and not v.strip())
# ---------------------------------------------------------------- loading
def load_invoices(path_or_text, is_csv=None):
text = path_or_text
if is_csv is None:
is_csv = not text.lstrip().startswith(("{", "["))
if not is_csv:
try:
data = json.loads(text)
except ValueError as e:
raise InputError("invalid JSON: %s" % e)
invs = data.get("invoices") if isinstance(data, dict) else data
if not isinstance(invs, list):
raise InputError("JSON must be a list of invoices or {\"invoices\": [...]}")
rules = data.get("rules") if isinstance(data, dict) else None
return invs, rules, []
rows = list(csv.DictReader(io.StringIO(text.lstrip("\ufeff"))))
if not rows:
raise InputError("CSV has no rows")
cols = set(rows[0].keys())
if "invoice_number" not in cols or not ({"qty", "unit_price"} <= cols):
raise InputError("CSV needs at least invoice_number, qty and unit_price columns")
invs, order, notes = {}, [], []
for n, row in enumerate(rows, 2):
key = ((row.get("seller_vat_id") or row.get("seller_name") or "").strip(), (row.get("invoice_number") or "").strip())
if key not in invs:
inv = {"_rows": [n]}
for c, p in CSV_MAP.items():
if c in row and not blank(row[c]):
setp(inv, p, row[c].strip())
invs[key] = inv; order.append(key)
else:
inv = invs[key]; inv["_rows"].append(n)
for c, p in CSV_MAP.items():
if c in row and not blank(row[c]) and not blank(get(inv, p)) and str(get(inv, p)) != row[c].strip():
notes.append((inv, "row %d: %s is '%s' but earlier rows say '%s'" % (n, c, row[c].strip(), get(inv, p))))
elif c in row and not blank(row[c]) and blank(get(inv, p)):
setp(inv, p, row[c].strip())
line = {"_row": n}
for c, p in LINE_MAP.items():
if c in row and not blank(row[c]):
line[p] = row[c].strip()
inv.setdefault("lines", []).append(line)
return [invs[k] for k in order], None, notes
def parse_date(s):
try:
return datetime.date.fromisoformat(str(s)[:10]) if not blank(s) else None
except ValueError:
return "bad"
# ---------------------------------------------------------------- checking
def check(invoices, rules, today=None, csv_notes=()):
R = dict(DEFAULT_RULES); R.update(rules or {})
q = Decimal(1).scaleb(-int(R["currency_decimals"]))
mode = ROUND_HALF_EVEN if R["rounding"] == "half_even" else ROUND_HALF_UP
tol = Decimal(str(R["tolerance"]))
rnd = lambda x: x.quantize(q, rounding=mode)
allowed = None if R["allowed_vat_rates"] is None else {Decimal(str(r)) for r in R["allowed_vat_rates"]}
catmap = {k.lower(): Decimal(str(v)) for k, v in (R.get("category_rates") or {}).items()}
results, seen_numbers, seen_amounts = [], {}, {}
notes_by = {}
for inv, msg in csv_notes:
notes_by.setdefault(id(inv), []).append(msg)
for idx, inv in enumerate(invoices):
F = []
def f(sev, code, msg):
F.append((sev, code, msg))
num = inv.get("number")
label = str(num) if not blank(num) else "#%d (no number)" % (idx + 1)
if any(str(o.get("number")) == str(num) for o in invoices[:idx]) and not blank(num):
label += " (#%d)" % (idx + 1)
credit = str(inv.get("type", "")).lower().replace(" ", "_") in ("credit_note", "credit", "refund")
for p in R["required_invoice_fields"]:
if blank(get(inv, p)):
f("high" if p in ("number", "date") else "medium", "IV01", "missing invoice field '%s'" % p)
for m in notes_by.get(id(inv), []):
f("medium", "IV13", "inconsistent header across CSV rows: " + m)
d = parse_date(inv.get("date")); dd = parse_date(inv.get("due_date"))
if d == "bad":
f("high", "IV10", "invoice date '%s' is not YYYY-MM-DD" % inv.get("date")); d = None
if dd == "bad":
f("medium", "IV10", "due date '%s' is not YYYY-MM-DD" % inv.get("due_date")); dd = None
if d and dd and dd < d:
f("medium", "IV10", "due date %s is before the invoice date %s" % (dd, d))
if d and today and d > today:
f("medium", "IV10", "invoice date %s is in the future (today %s)" % (d, today))
lines, per_rate, sigs = [], {}, {}
sum_net = sum_vat_line = Decimal(0)
for ln_i, ln in enumerate(inv.get("lines") or [], 1):
L = {"n": ln_i, "desc": str(ln.get("description") or ln.get("sku") or "")[:40], "issues": []}
where = "line %d (%s)" % (ln_i, L["desc"] or "no description")
for p in R["required_line_fields"]:
if blank(ln.get(p)):
f("high" if p in ("qty", "unit_price") else "medium", "IV01", "%s: missing '%s'" % (where, p)); L["issues"].append("missing " + p)
try:
qty = D(ln.get("qty"), "quantity"); up = D(ln.get("unit_price"), "unit price")
disc = D(ln.get("discount_pct"), "discount") or Decimal(0)
rate = D(ln.get("vat_rate"), "tax rate")
s_net, s_vat, s_gross = D(ln.get("net")), D(ln.get("vat")), D(ln.get("gross"))
except InputError as e:
f("high", "IV01", "%s: %s" % (where, e)); L["issues"].append("unparseable"); lines.append(L); continue
if rate is not None and Decimal(0) < rate < 1:
f("low", "IV04", "%s: tax rate %s looks like a fraction - read as %s%%" % (where, rate, rate * 100)); rate = rate * 100
if qty is None or up is None:
lines.append(L); continue
if disc < 0 or disc > Decimal(str(R["max_discount_pct"])):
f("high", "IV08", "%s: discount %s%% is outside 0-%s%%" % (where, disc, R["max_discount_pct"])); L["issues"].append("discount")
if (qty < 0 or up < 0) and not credit:
f("medium", "IV09", "%s: negative quantity or price on an invoice - should this be a credit note?" % where); L["issues"].append("negative")
if qty == 0 or up == 0:
f("low", "IV09", "%s: zero quantity or price" % where)
r = rate if rate is not None else Decimal(0)
base = qty * up * (1 - disc / 100)
if R["prices_include_vat"]:
gross = rnd(base); vat = rnd(gross * r / (100 + r)); net = gross - vat
else:
net = rnd(base); vat = rnd(net * r / 100); gross = net + vat
L.update(qty=qty, up=up, disc=disc, rate=rate, net=net, vat=vat, gross=gross, s_net=s_net, s_vat=s_vat, s_gross=s_gross)
if rate is not None:
if allowed is not None and rate not in allowed:
f("high", "IV04", "%s: tax rate %s%% is not in your allowed rates (%s)" % (where, plain(rate), ", ".join(plain(x) for x in sorted(allowed))))
L["issues"].append("rate")
cat = str(ln.get("category") or "").lower()
if cat in catmap and catmap[cat] != rate:
f("medium", "IV04", "%s: category '%s' is %s%% in your rate list but the line uses %s%%" % (where, cat, plain(catmap[cat]), plain(rate)))
L["issues"].append("category rate")
if s_net is not None and abs(s_net - net) > tol:
f("high", "IV02", "%s: line net %s but %s x %s%s = %s (diff %s)" % (where, s_net, plain(qty), up, " - %s%%" % plain(disc) if disc else "", net, s_net - net))
L["issues"].append("net")
if s_vat is not None and abs(s_vat - vat) > tol:
exp_on_stated = rnd((s_net if s_net is not None else net) * r / 100)
if s_net is not None and abs(s_vat - exp_on_stated) <= tol:
pass # consistent with its own (wrong) net; already reported as IV02
else:
f("high", "IV03", "%s: tax %s but %s%% of %s = %s (diff %s)" % (where, s_vat, plain(r), net, vat, s_vat - vat))
L["issues"].append("tax")
if s_gross is not None and s_net is not None and s_vat is not None and abs(s_gross - (s_net + s_vat)) > tol:
f("high", "IV02", "%s: line gross %s is not net %s + tax %s" % (where, s_gross, s_net, s_vat)); L["issues"].append("gross")
for v, nm in ((s_net, "net"), (s_vat, "tax"), (s_gross, "gross")):
if v is not None and v != v.quantize(q, rounding=mode):
f("low", "IV12", "%s: stated %s %s has more than %s decimals" % (where, nm, v, R["currency_decimals"]))
sig = (str(ln.get("sku") or "").lower(), L["desc"].lower(), qty, up)
if sig in sigs:
f("medium", "IV07", "%s repeats line %d (same item, quantity and price) - duplicate?" % (where, sigs[sig])); L["issues"].append("duplicate")
else:
sigs[sig] = ln_i
pr = per_rate.setdefault(r, [Decimal(0), Decimal(0)])
pr[0] += net; pr[1] += vat
sum_net += net; sum_vat_line += vat
lines.append(L)
if not lines:
f("high", "IV01", "invoice has no lines")
# invoice-level discount reduces the taxable base proportionally
idisc = D(inv.get("invoice_discount_pct"), "invoice discount") if not blank(inv.get("invoice_discount_pct")) else None
if idisc:
if idisc < 0 or idisc > 100:
f("high", "IV08", "invoice discount %s%% is outside 0-100%%" % idisc)
factor = 1 - idisc / 100
for r in per_rate:
n_r = rnd(per_rate[r][0] * factor)
per_rate[r] = [n_r, rnd(n_r * r / 100)]
sum_net = sum(v[0] for v in per_rate.values())
sum_vat_line = sum(v[1] for v in per_rate.values())
f("info", "IV08", "invoice discount %s%% applied before tax (taxable base reduced on every rate)" % idisc)
vat_invoice_mode = sum((rnd(v[0] * r / 100) for r, v in per_rate.items()), Decimal(0))
exp_vat = sum_vat_line if R["vat_rounding"] == "line" else vat_invoice_mode
alt_vat = vat_invoice_mode if R["vat_rounding"] == "line" else sum_vat_line
exp = {"net": sum_net, "vat": exp_vat, "gross": sum_net + exp_vat}
if R["prices_include_vat"] and not idisc:
exp["gross"] = sum(L.get("gross", 0) for L in lines)
exp["net"] = exp["gross"] - exp_vat
T = inv.get("totals") or {}
try:
st = {k: D(T.get(k), "total") for k in ("net", "vat", "gross")}
except InputError as e:
f("high", "IV05", str(e)); st = {"net": None, "vat": None, "gross": None}
if all(v is None for v in st.values()):
f("high", "IV01", "no stated totals (net / tax / gross) to check")
for k, nm in (("net", "net total"), ("vat", "tax total"), ("gross", "grand total")):
if st[k] is None:
continue
diff = st[k] - exp[k]
if abs(diff) <= tol:
if k == "vat" and diff and st[k] == alt_vat:
f("info", "IV06", "tax total %s is %s rounding (%s rounding gives %s) - within tolerance" % (
st[k], "per-invoice" if R["vat_rounding"] == "line" else "per-line", R["vat_rounding"], exp_vat))
continue
if k == "vat" and abs(st[k] - alt_vat) <= tol:
f("low", "IV06", "tax total %s matches %s rounding (%s) instead of your %s rounding (%s) - a rounding-method difference of %s" % (
st[k], "per-invoice" if R["vat_rounding"] == "line" else "per-line", alt_vat, R["vat_rounding"], exp_vat, diff))
exp["vat"] = alt_vat; exp["gross"] = exp["net"] + alt_vat
continue
if k == "gross" and st["net"] is not None and st["vat"] is not None and abs(st["gross"] - (st["net"] + st["vat"])) <= tol and (abs(st["net"] - exp["net"]) > tol or abs(st["vat"] - exp["vat"]) > tol):
continue # follows from the net/tax error already reported
sev = "high" if abs(diff) > tol * max(1, len(lines)) else "medium"
f(sev, "IV05", "stated %s %s but the lines add up to %s (diff %s)" % (nm, st[k], exp[k], diff))
if st["net"] is not None and st["vat"] is not None and st["gross"] is not None and abs(st["gross"] - st["net"] - st["vat"]) > tol:
f("high", "IV05", "stated grand total %s is not stated net %s + tax %s" % (st["gross"], st["net"], st["vat"]))
for b in inv.get("vat_breakdown") or []:
try:
br, bn, bv = D(b.get("rate")), D(b.get("net")), D(b.get("vat"))
except InputError as e:
f("medium", "IV11", "tax breakdown: %s" % e); continue
got = per_rate.get(br)
if got is None:
f("medium", "IV11", "tax breakdown lists %s%% but no line uses that rate" % br)
elif (bn is not None and abs(bn - got[0]) > tol) or (bv is not None and abs(bv - got[1]) > tol and abs(bv - rnd(got[0] * br / 100)) > tol):
f("medium", "IV11", "tax breakdown %s%%: stated base %s / tax %s, lines give %s / %s" % (br, bn, bv, got[0], got[1]))
seller = str(get(inv, "seller.vat_id") or get(inv, "seller.name") or "").strip().lower()
if not blank(num):
k = (seller, str(num).strip().lower())
if k in seen_numbers:
f("high", "IV07", "invoice number %s from this seller already appears (invoice #%d) - duplicate invoice?" % (num, seen_numbers[k] + 1))
else:
seen_numbers[k] = idx
if st["gross"] is not None and d:
k = (seller, d, st["gross"])
if k in seen_amounts and str(invoices[seen_amounts[k]].get("number")) != str(num):
f("medium", "IV07", "same seller, date and total %s as invoice %s - possible duplicate under a new number" % (st["gross"], invoices[seen_amounts[k]].get("number")))
seen_amounts.setdefault(k, idx)
F.sort(key=lambda x: (SEV[x[0]], x[1]))
results.append({"label": label, "seller": get(inv, "seller.name") or "", "currency": inv.get("currency") or "",
"credit": credit, "lines": lines, "per_rate": per_rate, "expected": exp, "stated": st,
"findings": F, "rows": inv.get("_rows")})
return results, R
# ---------------------------------------------------------------- rendering
def plain(v):
"""Plain decimal without exponent or trailing zeros (250, 0.0185, 12.5)."""
if v is None:
return "-"
t = format(v.normalize(), "f")
return t
def money(v):
return "-" if v is None else "{:,.2f}".format(v)
def report(results, R, src):
tot = {s: sum(1 for r in results for f in r["findings"] if f[0] == s) for s in SEV}
bad = [r for r in results if any(f[0] == "high" for f in r["findings"])]
out = ["# Invoice check: %s" % src, ""]
out.append("%d invoice(s), %d line(s). Rules: %s rounding, tax rounded per %s, tolerance %s, prices %s tax%s." % (
len(results), sum(len(r["lines"]) for r in results), R["rounding"].replace("_", " "), R["vat_rounding"], R["tolerance"],
"include" if R["prices_include_vat"] else "exclude",
", allowed rates " + "/".join(str(x) for x in R["allowed_vat_rates"]) + "%" if R["allowed_vat_rates"] is not None else ""))
out += ["", "**Verdict: %s**" % ("%d of %d invoice(s) have high findings - do not pay/send until fixed" % (len(bad), len(results)) if bad else "no high findings"),
"", "Findings: %d high, %d medium, %d low, %d info." % (tot["high"], tot["medium"], tot["low"], tot["info"]), "",
"This is an arithmetic and consistency check against your own rules, not tax advice: confirm rates and required invoice fields with your accountant or tax authority.", "",
"## Invoices", "", "| Invoice | Seller | Lines | Stated total | Recomputed | Difference | High | Medium | Low | Status |", "|---|---|---|---|---|---|---|---|---|---|"]
for r in results:
c = {s: sum(1 for f in r["findings"] if f[0] == s) for s in SEV}
sg, eg = r["stated"]["gross"], r["expected"]["gross"]
out.append("| %s%s | %s | %d | %s %s | %s | %s | %d | %d | %d | %s |" % (
r["label"], " (credit note)" if r["credit"] else "", r["seller"], len(r["lines"]), money(sg), r["currency"], money(eg),
money(sg - eg) if sg is not None else "-", c["high"], c["medium"], c["low"],
"FIX" if c["high"] else ("CHECK" if c["medium"] else "OK")))
for r in results:
if not r["findings"] or all(f[0] == "info" for f in r["findings"]):
continue
out += ["", "## %s - %s" % (r["label"], r["seller"]), ""]
out += ["| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |", "|---|---|---|---|---|---|---|---|---|"]
for L in r["lines"]:
if "net" not in L:
out.append("| %d | %s | - | - | - | - | - | - | %s |" % (L["n"], L["desc"], ", ".join(L["issues"])))
continue
out.append("| %d | %s | %s | %s | %s | %s | %s / %s | %s / %s | %s |" % (
L["n"], L["desc"], plain(L["qty"]), L["up"], plain(L["disc"]) if L["disc"] else "", plain(L["rate"]),
money(L["s_net"]), money(L["net"]), money(L["s_vat"]), money(L["vat"]), ", ".join(L["issues"]) or "ok"))
out += ["", "| Totals | Stated | Recomputed |", "|---|---|---|"]
for k, nm in (("net", "Net"), ("vat", "Tax"), ("gross", "Total")):
out.append("| %s | %s | %s |" % (nm, money(r["stated"][k]), money(r["expected"][k])))
if len(r["per_rate"]) > 1:
out.append("| Tax by rate | | %s |" % "; ".join("%s%%: %s on %s" % (plain(k), money(v[1]), money(v[0])) for k, v in sorted(r["per_rate"].items(), reverse=True)))
out += [""] + ["- **%s %s** %s" % (s.upper(), c, m) for s, c, m in r["findings"]]
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def inv(lines, totals, **kw):
base = {"number": "A1", "date": "2026-10-01", "seller": {"name": "S", "vat_id": "X1"}, "buyer": {"name": "B"},
"currency": "EUR", "lines": lines, "totals": totals}
base.update(kw); return base
codes = lambda res: {f[1] for f in res[0]["findings"]}
sev = lambda res, c: {f[0] for f in res[0]["findings"] if f[1] == c}
ok = [{"description": "a", "qty": 2, "unit_price": "10.00", "vat_rate": 20, "net": "20.00", "vat": "4.00", "gross": "24.00"}]
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"})], {})
t("clean invoice has no findings", not res[0]["findings"])
res, _ = check([inv([dict(ok[0], net="22.00", vat="4.40", gross="26.40")], {"net": "22.00", "vat": "4.40", "gross": "26.40"})], {})
t("wrong line net -> IV02 high", "high" in sev(res, "IV02"))
t("tax consistent with wrong net not double-reported", "IV03" not in codes(res))
res, _ = check([inv([dict(ok[0], vat="3.60", gross="23.60")], {"net": "20.00", "vat": "3.60", "gross": "23.60"})], {})
t("wrong line tax -> IV03", "IV03" in codes(res))
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"})], {"allowed_vat_rates": [10, 0]})
t("rate not allowed -> IV04 high", "high" in sev(res, "IV04"))
res, _ = check([inv([dict(ok[0], category="books")], {"net": "20.00", "vat": "4.00", "gross": "24.00"})], {"category_rates": {"books": 0}})
t("category rate mismatch -> IV04 medium", "medium" in sev(res, "IV04"))
ten = [{"description": "x%d" % i, "qty": 1, "unit_price": "0.05", "vat_rate": 10} for i in range(10)]
res, _ = check([inv(ten, {"net": "0.50", "vat": "0.05", "gross": "0.55"})], {})
t("per-invoice rounding detected -> IV06 low", "IV06" in codes(res) and "IV05" not in codes(res))
res, _ = check([inv(ten, {"net": "0.50", "vat": "0.10", "gross": "0.60"})], {})
res2, _ = check([inv(ten[:2], {"net": "0.10", "vat": "0.01", "gross": "0.11"})], {})
t("rounding difference within tolerance -> IV06 info", sev(res2, "IV06") == {"info"})
t("per-line rounding: 10 x 0.01 = 0.10 ok", not res[0]["findings"])
res, _ = check([inv(ten, {"net": "0.50", "vat": "0.05", "gross": "0.55"})], {"vat_rounding": "invoice"})
t("vat_rounding=invoice accepts 0.05", not res[0]["findings"])
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "25.00"})], {})
t("grand total wrong -> IV05 high", "high" in sev(res, "IV05"))
d = [{"description": "a", "qty": 1, "unit_price": "100", "discount_pct": 15, "vat_rate": 20}]
res, _ = check([inv(d, {"net": "85.00", "vat": "17.00", "gross": "102.00"})], {})
t("line discount applied before tax", not res[0]["findings"])
res, _ = check([inv(d, {"net": "85.00", "vat": "17.00", "gross": "102.00"}, invoice_discount_pct="10")], {})
t("invoice discount reduces base", res[0]["expected"]["gross"] == Decimal("91.80"))
res, _ = check([inv(ok + ok, {"net": "40.00", "vat": "8.00", "gross": "48.00"})], {})
t("duplicate line -> IV07 medium", "medium" in sev(res, "IV07"))
a = inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"})
res, _ = check([a, dict(a)], {})
t("duplicate invoice number -> IV07 high", any(f[1] == "IV07" and f[0] == "high" for f in res[1]["findings"]))
res, _ = check([a, dict(a, number="A2")], {})
t("same seller/date/total new number -> IV07 medium", any(f[1] == "IV07" and f[0] == "medium" for f in res[1]["findings"]))
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"}, number="", seller={"name": "S"})], {})
t("missing number high, missing vat id medium", "high" in sev(res, "IV01") and "medium" in sev(res, "IV01"))
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"}, due_date="2026-09-01")], {}, today=datetime.date(2026, 9, 1))
t("due before date + future date -> IV10", len([f for f in res[0]["findings"] if f[1] == "IV10"]) == 2)
neg = [{"description": "return", "qty": -1, "unit_price": "10", "vat_rate": 20}]
res, _ = check([inv(neg, {"net": "-10.00", "vat": "-2.00", "gross": "-12.00"})], {})
t("negative line on invoice -> IV09", "IV09" in codes(res))
res, _ = check([inv(neg, {"net": "-10.00", "vat": "-2.00", "gross": "-12.00"}, type="credit_note")], {})
t("negative ok on credit note", "IV09" not in codes(res))
g = [{"description": "a", "qty": 1, "unit_price": "12.00", "vat_rate": 20}]
res, _ = check([inv(g, {"net": "10.00", "vat": "2.00", "gross": "12.00"})], {"prices_include_vat": True})
t("tax-inclusive prices", not res[0]["findings"])
t("decimal comma parsing", D("1.234,56") == Decimal("1234.56") and D("12,5") == Decimal("12.5") and D("1,234.56") == Decimal("1234.56"))
csvt = ("invoice_number,invoice_date,seller_name,seller_vat_id,buyer_name,currency,description,qty,unit_price,vat_rate,line_net,vat_amount,total_net,total_vat,total_gross\n"
"C1,2026-10-01,S,X1,B,EUR,a,1,10,20,10.00,2.00,15.00,3.00,18.00\nC1,2026-10-01,S,X1,B,USD,b,1,5,20,5.00,1.00,,,\n")
invs, _, notes = load_invoices(csvt)
res, _ = check(invs, {}, csv_notes=notes)
t("CSV grouped into one invoice", len(invs) == 1 and len(invs[0]["lines"]) == 2 and not any(f[0] == "high" for f in res[0]["findings"]))
t("CSV inconsistent header -> IV13", "IV13" in codes(res))
try:
load_invoices("{not json"); okk = False
except InputError:
okk = True
t("bad JSON rejected", okk)
res, _ = check([inv([dict(ok[0], qty="two")], {"gross": "24"})], {})
t("unparseable qty -> IV01 high", "high" in sev(res, "IV01"))
rep = report(*check([a], {}), "x")
t("report has verdict and not-tax-advice line", "Verdict" in rep and "not tax advice" in rep)
for name, okk in tests:
print(("PASS " if okk else "FAIL ") + name)
n = sum(1 for _, okk in tests if okk)
print("%d/%d passed" % (n, len(tests)))
return 0 if n == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("invoices", nargs="?", help="invoices .json or line items .csv")
ap.add_argument("--rules", help="rules JSON (rates, rounding, tolerance, required fields)")
ap.add_argument("--today", help="YYYY-MM-DD for future-date checks (default: today)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.invoices:
ap.error("invoices file required (or --self-test)")
try:
try:
text = open(a.invoices, encoding="utf-8-sig").read()
except OSError as e:
raise InputError(str(e))
invs, emb_rules, notes = load_invoices(text, a.invoices.lower().endswith(".csv") or None)
rules = dict(emb_rules or {})
if a.rules:
try:
rules.update(json.load(open(a.rules, encoding="utf-8")))
except (OSError, ValueError) as e:
raise InputError("rules: %s" % e)
unknown = set(rules) - set(DEFAULT_RULES) - {"name", "note"}
if unknown:
raise InputError("unknown rules key(s): %s" % ", ".join(sorted(unknown)))
if rules.get("vat_rounding", "line") not in ("line", "invoice") or rules.get("rounding", "half_up") not in ("half_up", "half_even"):
raise InputError("vat_rounding must be line|invoice and rounding half_up|half_even")
today = datetime.date.fromisoformat(a.today) if a.today else datetime.date.today()
results, R = check(invs, rules, today, notes)
except (InputError, ValueError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
conv = lambda v: str(v) if isinstance(v, Decimal) else v
out = [{"invoice": r["label"], "seller": r["seller"], "currency": r["currency"],
"stated": {k: conv(v) for k, v in r["stated"].items()}, "recomputed": {k: conv(v) for k, v in r["expected"].items()},
"tax_by_rate": {str(k): [str(v[0]), str(v[1])] for k, v in r["per_rate"].items()},
"findings": [{"severity": s, "code": c, "message": m} for s, c, m in r["findings"]]} for r in results]
print(json.dumps({"invoices": out}, indent=2))
else:
print(report(results, R, a.invoices))
return 1 if any(f[0] == "high" for r in results for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Turns a git log in Conventional Commits format into a ready-to-paste Keep a Changelog release section - Added, Changed, Deprecated, Removed, Fixed, Security - with scopes, issue and commit links and a breaking-changes block. Flags breaking changes without migration notes, non-conforming and fixup commits, reverts and duplicates, and suggests the next SemVer version. Includes an offline stdlib Python builder with --self-test.
---
name: conventional-commits-changelog-builder
description: Turns a git log in Conventional Commits format into a ready-to-paste Keep a Changelog release section - Added, Changed, Deprecated, Removed, Fixed, Security - with scopes, issue and commit links and a breaking-changes block. Flags breaking changes without migration notes, non-conforming and fixup commits, reverts and duplicates, and suggests the next SemVer version. Includes an offline stdlib Python builder with --self-test.
---
# Conventional Commits Changelog Builder
You help maintainers turn the commits since the last tag into a changelog entry people can actually read. Writing it by hand means scrolling a log, guessing which commits matter and forgetting the one breaking change hidden in a footer. Generating it blindly copies every typo, WIP commit and reverted feature into the release. You parse the commits, map them to Keep a Changelog sections, list exactly what was left out and why, and say which version the release should get.
## Files in this skill
- `scripts/changelog_build.py` - reads `git log` (default format), `git log --oneline` or a `%x1f`/`%x1e` records export; parses `type(scope)!: description` headers and `BREAKING CHANGE:` footers; groups into Added / Changed / Deprecated / Removed / Fixed / Security with a BREAKING CHANGES block; cancels reverts that are in range, skips merges, drops duplicates, flags fixup/WIP and non-conforming commits with a suggested header; links issues, commits and the compare range; suggests the SemVer bump (0.x aware); can insert the section into an existing CHANGELOG.md (Python 3.8+ standard library only)
- `references/commit-to-changelog-mapping.md` - the type -> section table, breaking-change rules, bump rules and the finding codes CC01-CC10
- `references/release-hygiene.md` - writing entries for users, migration notes, what to do with non-conforming history, and CI checks
- `templates/release-notes-review.md` - the review to hand back with the section
- `examples/git-log.txt` - 21 commits of a fictional CLI (tidyq) since v2.3.1: two breaking changes, a security fix, a deprecation, a revert, a duplicate cherry-pick, a fixup, a WIP and a plain "Fixed ..." commit
- `examples/CHANGELOG.md` - the project's existing changelog (Unreleased + 2.3.1 + 2.3.0)
- `examples/changelog-report.md` - the real builder output on that log
## Workflow
### 1. Get the commit range
Ask for the last released tag and export everything after it, newest first:
```
git log v2.3.1..HEAD > commits.txt
git log v2.3.1..HEAD --format='%H%x1f%s%x1f%b%x1e' > commits.txt # safest: keeps bodies, no parsing guesswork
```
Also ask for the current version, the release date, the repository URL (for links), the tag prefix (`v` by default) and whether internal commits (refactor, docs, ci, chore, test) should be listed. A squash-merge workflow is ideal: one PR = one commit = one line.
### 2. Run the builder
```
python3 scripts/changelog_build.py examples/git-log.txt --current 2.3.1 --date 2026-11-03 --repo-url https://github.com/tidyq/tidyq
python3 scripts/changelog_build.py commits.txt --current 2.3.1 --insert CHANGELOG.md > CHANGELOG.new.md
python3 scripts/changelog_build.py commits.txt --current 0.9.4 --include-internal --json
python3 scripts/changelog_build.py --self-test
```
Exit 0 = ready to tag, 1 = high findings, 2 = bad input. Without a shell, apply `references/commit-to-changelog-mapping.md` by hand: parse each header, map type to section, collect `!` and `BREAKING CHANGE:` footers, cancel reverts, then pick the highest bump.
### 3. Fix the findings before tagging
- CC03 (high): a `!` commit with no `BREAKING CHANGE:` footer gives users no migration path. Write the note (what breaks, what to do instead) into the section.
- CC08 (high): non-conforming commits that look like features or fixes are missing from the changelog. Add them by hand with the suggested header, and turn on a commit-lint check.
- CC07: fixup/WIP commits should be squashed before the release; CC04: reverts of older releases need a sentence explaining why.
- CC01/CC02/CC05/CC06: wording and conformance; fix in the changelog text, not by rewriting published history.
### 4. Edit for humans
The builder gives you correct structure, not good prose. Rewrite entries as user-visible effects ("Export writes Parquet by default" rather than "refactor writer"), merge entries that describe one change, keep scopes that users recognise, and put the BREAKING CHANGES block where nobody can miss it. See `references/release-hygiene.md`.
### 5. Deliver
Fill `templates/release-notes-review.md`: version and why, the final section, the migration notes, what was left out, and follow-ups (commitlint, PR title check). With `--insert`, hand back the updated CHANGELOG.md; the original file is never modified.
## Example result
On `examples/git-log.txt` (current 2.3.1) the builder reads 21 commits, 85% conforming, and suggests **3.0.0**: `feat(export)!` makes Parquet the default (with a proper BREAKING CHANGE footer) and `fix(config)!` drops the INI config file without any migration note (CC03, high). "Fixed crash when the config file is empty" is not a Conventional Commit and would silently miss the changelog (CC08, high; suggested `fix: crash when the config file is empty`). The token-leak fix lands under Security, the `--legacy-dates` deprecation under Deprecated, the colour-themes feature and its revert cancel out, the cherry-picked BOM fix is listed once, and the merge, fixup, WIP and 5 internal commits are listed as left out.
## Rules
- Never invent entries: every line comes from a commit, and every commit is either in the section or in the "left out" list.
- A breaking change always gets a migration note and the major bump (minor while on 0.x); never hide it in Changed.
- Security fixes go under Security even when the commit type is `fix`.
- Do not rewrite published history to fix commit messages; fix the changelog text and add a lint check for the future.
- Keep the newest release on top, dates as YYYY-MM-DD and one compare link per version.
- The version is a suggestion: the maintainer decides, especially for behaviour changes typed as `fix`.
FILE:references/commit-to-changelog-mapping.md
# Commit -> changelog mapping
## Header grammar (Conventional Commits 1.0.0)
```
<type>[(<scope>)][!]: <description>
[body]
[footers] e.g. BREAKING CHANGE: <what breaks and how to migrate>
Closes #123
Refs: #98
```
- `type` is a noun such as feat or fix; lower case by convention.
- Exactly one space after the colon. `fix:missing space` or `fix : x` is malformed (CC01, with a suggested fix).
- `!` before the colon marks a breaking change. A `BREAKING CHANGE:` (or `BREAKING-CHANGE:`) footer also marks one, with or without `!`.
## Type -> Keep a Changelog section
| Commit | Section | Bump |
|---|---|---|
| feat | Added | minor |
| feat with "deprecate" in the description | Deprecated | minor |
| feat / breaking with remove, drop, delete first | Removed | minor / major |
| fix | Fixed | patch |
| fix / deps mentioning security, CVE, vulnerability, XSS, CSRF, injection, leak; type security | Security | patch |
| perf, deps | Changed | patch |
| any type with `!` or BREAKING CHANGE footer | its section + BREAKING CHANGES block | major (minor on 0.x) |
| refactor, docs, style, test, build, ci, chore | not listed (Internal with --include-internal) | none |
| revert of a commit in the same range | both left out | none |
| revert of an older commit | Removed (reverted feat) or Fixed | patch |
| merge commits | left out | none |
| fixup!, squash!, amend!, WIP | left out + CC07 | none |
Aliases accepted with a CC02 warning: feature/features/add -> feat, bugfix/bug/hotfix/fixes/fixed -> fix, doc/documentation -> docs, tests -> test, performance -> perf, refactoring -> refactor, cleanup/release -> chore, dep -> deps. Other unknown types are left out.
## Bump rules
- Highest wins: major > minor > patch. No feat/fix/perf/security/breaking change = no release needed (CC09).
- 1.0.0 and later: breaking -> X+1.0.0. 0.y.z: breaking -> 0.y+1.0 (CC10 reminds you to plan 1.0.0).
- Pre-release and build suffixes on --current are ignored for the bump; add them yourself (`3.0.0-rc.1`).
## Finding codes
| Code | Severity | Meaning |
|---|---|---|
| CC01 | medium | header is not a Conventional Commit (suggested header given) |
| CC02 | medium | unknown type (alias mapped or left out) |
| CC03 | high / info | breaking without BREAKING CHANGE footer (high); footer-only breaking (info) |
| CC04 | info / medium | revert cancelled in range (info); revert of an older release (medium) |
| CC05 | low | duplicate commit (cherry-pick) listed once |
| CC06 | low | style: upper-case type, trailing period, header over 72 chars |
| CC07 | medium | fixup/squash/WIP commit in the release range |
| CC08 | high | non-conforming commits that look like feat/fix are missing from the changelog |
| CC09 | info | no user-facing changes |
| CC10 | info | 0.x major-zero bump rule applied |
FILE:references/release-hygiene.md
# Release hygiene
## Writing entries users understand
- Describe the effect, not the implementation: "Export writes Parquet by default" beats "switch writer backend".
- Start with a verb in the imperative or a noun phrase, capitalise the first word, no trailing period. Keep one change per line.
- Keep scopes users know (cli, export, auth). Replace internal module names with the feature name.
- Merge several commits that describe one change into one entry and keep all links.
- Keep links: issue numbers for context, commit hashes for auditors, one compare link per version at the bottom.
## Migration notes for breaking changes
A good note answers three questions in one or two sentences:
1. What stops working? ("`tidyq export` without --format now writes .parquet")
2. Who is affected? ("scripts that expect a .csv file")
3. What do they do instead? ("pass --format csv")
Put every note in the BREAKING CHANGES block at the top of the release section and link a longer upgrade guide if needed.
## Keep a Changelog basics
- File `CHANGELOG.md`, newest version first, an `## [Unreleased]` section on top for work in progress.
- Version headings `## [3.0.0] - 2026-11-03` (ISO dates), sections in this order: Added, Changed, Deprecated, Removed, Fixed, Security. Leave out empty sections.
- Mark yanked releases `## [2.2.0] - 2026-09-01 [YANKED]`.
## Non-conforming history
- Do not rewrite pushed history to fix messages. Add the missing entries to the changelog by hand (the builder lists them with a suggested header).
- Squash fixup!/WIP commits before merging; with squash-merge, the PR title becomes the commit header, so lint PR titles.
- Cherry-picked commits on release branches appear twice in some ranges; list them once.
## CI checks worth adding
- commitlint (or a PR-title check) with the conventional config on every pull request.
- A release job that runs the builder on `last-tag..HEAD`, fails on CC03/CC08, and opens the changelog diff for review instead of committing it blindly.
- Tag only after the changelog PR is merged, so the tag points at a commit that contains its own notes.
FILE:templates/release-notes-review.md
# Release notes review: <project> <current> -> <next>
**Range:** <last tag>..<commit> **Date:** <YYYY-MM-DD> **Commits:** <n> (<conformance %> conforming)
## Version
<next> - <major/minor/patch> because <breaking change / new feature / fixes only>.
<Any reason to deviate, e.g. behaviour change typed as fix, 0.x rule>
## Breaking changes and migration
| Change | Who is affected | What to do |
|---|---|---|
| <change> | <users/scripts/integrations> | <migration step> |
## Changelog section (final, edited)
```markdown
## [<next>] - <date>
...
```
## Left out on purpose
| Commit | Subject | Why |
|---|---|---|
| <hash> | <subject> | <merge / revert / duplicate / internal / WIP> |
## Fixed before tagging
- [ ] Migration note written for every breaking change (CC03)
- [ ] Missing user-facing commits added by hand (CC08)
- [ ] Fixup/WIP commits squashed (CC07)
## Follow-ups
- <commitlint / PR title check / upgrade guide / announce deprecations>
FILE:examples/git-log.txt
commit 9f1c2a7e4b000000000000000000000000000000
Merge: 8e0b1d6 5b7e8a3
Author: Elif Demir <elif@tidyq.dev>
Date: Mon Nov 2 17:42:10 2026 +0300
Merge pull request #212 from tidyq/feat/parquet-export
commit 8e0b1d6c3a000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Mon Nov 2 16:05:31 2026 +0300
feat(export)!: write Parquet by default for `tidyq export`
Parquet is now the default output format. CSV is still available.
BREAKING CHANGE: `tidyq export` without --format now writes .parquet instead of .csv; pass --format csv to keep the old behaviour.
Refs: #198
commit 7d9a0c5b2f000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Mon Nov 2 11:20:04 2026 +0300
fixup! feat(export)!: write Parquet by default for `tidyq export`
commit 6c8f9b4a1e000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Sun Nov 1 20:14:55 2026 +0300
fix(auth): prevent API token leak in debug logs (#207)
Tokens were printed in full when TIDYQ_DEBUG=1. They are now masked.
Closes #205
commit 5b7e8a3f0d000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Sun Nov 1 15:02:13 2026 +0300
feat(query): support window functions in `--where` (#203)
commit 4a6d7f2e9c000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Sat Oct 31 18:47:39 2026 +0300
perf(reader): stream large CSV files instead of loading them into memory
Peak memory on a 2 GB file drops from 2.6 GB to 180 MB.
Closes #187
commit 3f5c6e1d8b000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Sat Oct 31 12:30:00 2026 +0300
Fixed crash when the config file is empty
commit 2e4b5d0c7a000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Fri Oct 30 19:11:42 2026 +0300
feat(cli): deprecate the --legacy-dates flag
It will be removed in 3.0. Use --date-format instead.
commit 1d3a4c9b6f000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Fri Oct 30 14:25:18 2026 +0300
Revert "feat(ui): colour themes for table output"
This reverts commit 0c2f3b8a5e000000000000000000000000000000.
Themes broke output on Windows terminals.
commit 0c2f3b8a5e000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Fri Oct 30 10:03:51 2026 +0300
feat(ui): colour themes for table output
commit fb1e2a7d4c000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Thu Oct 29 21:40:07 2026 +0300
feature: add `tidyq doctor` command to check the environment
commit ea0d196c3b000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Thu Oct 29 16:12:30 2026 +0300
fix(query): quote column names that contain spaces.
Closes #199
commit d9fc085b2a000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Thu Oct 29 09:55:02 2026 +0300
fix(config)!: drop support for the ~/.tidyqrc INI file
commit c8eb974a19000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Wed Oct 28 18:20:44 2026 +0300
docs: add a Parquet section to the README
commit b7da863f08000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Wed Oct 28 13:08:19 2026 +0300
chore(deps): bump pyarrow from 16.1.0 to 17.0.0
commit a6c9752e97000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Tue Oct 27 17:31:26 2026 +0300
ci: cache pip downloads in the test workflow
commit 95b8641d86000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Tue Oct 27 11:47:53 2026 +0300
test(reader): cover BOM and CRLF inputs
commit 84a7530c75000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Mon Oct 26 15:16:08 2026 +0300
fix(reader): handle UTF-8 BOM at the start of a file (#195)
commit 7396420b64000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Mon Oct 26 15:16:08 2026 +0300
fix(reader): handle UTF-8 BOM at the start of a file (#195)
commit 62853f9a53000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Mon Oct 26 10:02:40 2026 +0300
WIP tweak table widths
commit 5174e8f942000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Sun Oct 25 19:44:12 2026 +0300
refactor(query): split the parser into tokenizer and planner
FILE:examples/CHANGELOG.md
# Changelog
All notable changes to tidyq are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.3.1] - 2026-10-21
### Fixed
- **reader:** Detect semicolon-separated files exported by Excel (#181)
## [2.3.0] - 2026-10-02
### Added
- **query:** `--limit` and `--offset` options (#170)
### Fixed
- **cli:** Exit with code 2 on unknown options instead of 1 (#174)
[2.3.1]: https://github.com/tidyq/tidyq/compare/v2.3.0...v2.3.1
[2.3.0]: https://github.com/tidyq/tidyq/compare/v2.2.0...v2.3.0
FILE:examples/changelog-report.md
# Changelog build: examples/git-log.txt
21 commits read: 17 Conventional (85.0% of non-merge commits), 1 non-conforming, 1 merges, 2 fixup/WIP.
**Version: 2.3.1 -> 3.0.0 (major bump, driven by breaking changes)**
**Verdict: 2 high finding(s) - fix before tagging**
## Release section (paste under the Unreleased heading of CHANGELOG.md)
```markdown
## [3.0.0] - 2026-11-03
### BREAKING CHANGES
- **export:** Write Parquet by default for `tidyq export` - `tidyq export` without --format now writes .parquet instead of .csv; pass --format csv to keep the old behaviour.
- **config:** Drop support for the ~/.tidyqrc INI file - MIGRATION NOTE MISSING
### Added
- **BREAKING** **export:** Write Parquet by default for `tidyq export` ([#198](https://github.com/tidyq/tidyq/issues/198), [8e0b1d6](https://github.com/tidyq/tidyq/commit/8e0b1d6))
- **query:** Support window functions in `--where` ([#203](https://github.com/tidyq/tidyq/issues/203), [5b7e8a3](https://github.com/tidyq/tidyq/commit/5b7e8a3))
- Add `tidyq doctor` command to check the environment ([fb1e2a7](https://github.com/tidyq/tidyq/commit/fb1e2a7))
### Changed
- **reader:** Stream large CSV files instead of loading them into memory ([#187](https://github.com/tidyq/tidyq/issues/187), [4a6d7f2](https://github.com/tidyq/tidyq/commit/4a6d7f2))
### Deprecated
- **cli:** Deprecate the --legacy-dates flag ([2e4b5d0](https://github.com/tidyq/tidyq/commit/2e4b5d0))
### Removed
- **BREAKING** **config:** Drop support for the ~/.tidyqrc INI file ([d9fc085](https://github.com/tidyq/tidyq/commit/d9fc085))
### Fixed
- **query:** Quote column names that contain spaces ([#199](https://github.com/tidyq/tidyq/issues/199), [ea0d196](https://github.com/tidyq/tidyq/commit/ea0d196))
- **reader:** Handle UTF-8 BOM at the start of a file ([#195](https://github.com/tidyq/tidyq/issues/195), [84a7530](https://github.com/tidyq/tidyq/commit/84a7530))
### Security
- **auth:** Prevent API token leak in debug logs ([#205](https://github.com/tidyq/tidyq/issues/205), [#207](https://github.com/tidyq/tidyq/issues/207), [6c8f9b4](https://github.com/tidyq/tidyq/commit/6c8f9b4))
[3.0.0]: https://github.com/tidyq/tidyq/compare/v2.3.1...v3.0.0
```
## Findings
- **HIGH CC03** d9fc085 'fix(config)!: drop support for the ~/.tidyqrc INI file' is breaking (!) but has no 'BREAKING CHANGE:' footer - add a migration note
- **HIGH CC08** 1 non-conforming commit(s) look user-facing (3f5c6e1) and are NOT in the changelog - reword them or add the entries by hand
- **MEDIUM CC01** 3f5c6e1 Fixed crash when the config file is empty: not a Conventional Commit - suggest: fix: crash when the config file is empty
- **MEDIUM CC02** fb1e2a7 feature: add `tidyq doctor` command to check the environment: unknown type 'feature' - treated as 'feat'
- **MEDIUM CC07** 7d9a0c5 'fixup! feat(export)!: write Parquet by default for `tidyq export`' is a fixup/WIP commit on the release branch - squash it before tagging
- **MEDIUM CC07** 62853f9 'WIP tweak table widths' is a fixup/WIP commit on the release branch - squash it before tagging
- **LOW CC05** 7396420 duplicates 84a7530 ('fix(reader): handle UTF-8 BOM at the start of a file (#195)', cherry-pick or re-applied?) - listed once
- **LOW CC06** ea0d196 fix(query): quote column names that contain spaces.: description ends with a period
- **INFO CC04** 1d3a4c9 reverts 0c2f3b8 'feat(ui): colour themes for table output' in the same range - both left out
## Not in the changelog
| Commit | Subject | Why |
|---|---|---|
| 9f1c2a7 | Merge pull request #212 from tidyq/feat/parquet-export | merge commit |
| 7d9a0c5 | fixup! feat(export)!: write Parquet by default for `tidyq export` | fixup/wip commit |
| 3f5c6e1 | Fixed crash when the config file is empty | not a Conventional Commit |
| 1d3a4c9 | Revert "feat(ui): colour themes for table output" | revert (cancelled) |
| 0c2f3b8 | feat(ui): colour themes for table output | reverted |
| 7396420 | fix(reader): handle UTF-8 BOM at the start of a file (#195) | duplicate |
| 62853f9 | WIP tweak table widths | fixup/wip commit |
| - | 5 internal commit(s): chore, ci, docs, refactor, test | internal type (use --include-internal to list) |
FILE:scripts/changelog_build.py
#!/usr/bin/env python3
"""Conventional Commits -> Keep a Changelog release section builder (stdlib only, Python 3.8+).
Reads a commit list (git log default output, git log --oneline, or a records file made with
git log --format='%H%x1f%s%x1f%b%x1e'), parses Conventional Commits headers, groups the changes
into Keep a Changelog sections, flags breaking changes and non-conforming commits, handles
reverts, merges and fixup commits, and suggests the next SemVer version.
Usage:
changelog_build.py COMMITS.txt [--current 1.4.2] [--date 2026-11-03] [--repo-url URL]
[--tag-prefix v] [--include-internal] [--insert CHANGELOG.md] [--json]
changelog_build.py --self-test
Exit: 0 = ok, 1 = high findings (breaking change without migration note, commits that would be
lost from the changelog), 2 = bad input.
"""
import argparse, datetime, json, re, sys
HEADER = re.compile(r"^(?P<type>[A-Za-z][A-Za-z0-9_-]*)(?:\((?P<scope>[^()\s][^()]*)\))?(?P<bang>!)?: (?P<desc>\S.*)$")
LOOSE = re.compile(r"^(?P<type>[A-Za-z][A-Za-z0-9_-]*)\s*(?:\((?P<scope>[^()]*)\))?\s*(?P<bang>!)?\s*:\s*(?P<desc>.*)$")
FOOTER = re.compile(r"^(?P<token>BREAKING[ -]CHANGE|[A-Za-z][A-Za-z-]*)(?:: | #)(?P<value>.+)$")
ISSUE = re.compile(r"(?:(?<=\s)|^|\()#(\d+)\b")
REVERTS = re.compile(r"This reverts commit ([0-9a-f]{7,40})", re.I)
KNOWN = {"feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore", "revert", "security", "deps"}
ALIASES = {"feature": "feat", "features": "feat", "add": "feat", "bugfix": "fix", "bug": "fix", "hotfix": "fix",
"fixes": "fix", "fixed": "fix", "doc": "docs", "documentation": "docs", "tests": "test",
"performance": "perf", "refactoring": "refactor", "cleanup": "chore", "release": "chore", "dep": "deps"}
INTERNAL = {"refactor", "docs", "style", "test", "build", "ci", "chore"}
SECTIONS = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
SEV_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
class InputError(Exception):
pass
# ---------------------------------------------------------------- parsing input
def parse_commits(text):
"""Return list of dicts: hash, subject, body (oldest last, i.e. git log order)."""
text = text.replace("\r\n", "\n")
if "\x1e" in text:
out = []
for rec in text.split("\x1e"):
rec = rec.strip("\n")
if not rec.strip():
continue
parts = rec.split("\x1f")
if len(parts) < 2:
raise InputError("record without \\x1f separators: %r" % rec[:60])
out.append({"hash": parts[0].strip(), "subject": parts[1].strip(),
"body": (parts[2] if len(parts) > 2 else "").strip("\n")})
return out
lines = text.split("\n")
if any(re.match(r"^commit [0-9a-f]{7,40}\b", l) for l in lines):
out, cur, msg = [], None, []
def flush():
if cur is not None:
m = [l[4:] if l.startswith(" ") else l.strip() for l in msg]
while m and not m[0].strip():
m.pop(0)
while m and not m[-1].strip():
m.pop()
cur["subject"] = m[0].strip() if m else ""
cur["body"] = "\n".join(m[1:]).strip("\n")
out.append(cur)
for l in lines:
mm = re.match(r"^commit ([0-9a-f]{7,40})\b", l)
if mm:
flush()
cur, msg = {"hash": mm.group(1), "merge": False}, []
elif cur is not None and not msg and re.match(r"^(Merge|Author|AuthorDate|Commit|CommitDate|Date):", l):
if l.startswith("Merge:"):
cur["merge"] = True
if l.startswith("Author:"):
cur["author"] = l[7:].strip()
if l.startswith("Date:"):
cur["date"] = l[5:].strip()
elif cur is not None:
msg.append(l)
flush()
return out
out = []
for l in lines:
if not l.strip() or l.lstrip().startswith("#"):
continue
mm = re.match(r"^\s*(?:\*\s*)?([0-9a-f]{7,40})\s+(.*)$", l)
if mm:
out.append({"hash": mm.group(1), "subject": mm.group(2).strip(), "body": ""})
else:
out.append({"hash": "", "subject": l.strip(), "body": ""})
return out
def parse_footers(body):
"""Footers = trailing paragraph lines 'Token: value' / 'Token #value' / 'BREAKING CHANGE: ...'."""
feet, breaking = [], None
paras = re.split(r"\n\s*\n", body.strip()) if body.strip() else []
if paras:
last = paras[-1].split("\n")
if FOOTER.match(last[0]):
cur = None
for l in last:
m = FOOTER.match(l)
if m:
cur = [m.group("token"), m.group("value").strip()]
feet.append(cur)
elif cur:
cur[1] += " " + l.strip()
for tok, val in feet:
if tok in ("BREAKING CHANGE", "BREAKING-CHANGE"):
breaking = val
if breaking is None: # BREAKING CHANGE paragraph anywhere in the body
m = re.search(r"^BREAKING[ -]CHANGE: (.+?)(?:\n\s*\n|\Z)", body, re.M | re.S)
if m:
breaking = " ".join(m.group(1).split())
return feet, breaking
def classify(c):
"""Annotate one commit dict in place."""
s = c["subject"]
c["issues"] = sorted(set(ISSUE.findall(s + "\n" + c.get("body", ""))), key=int)
c["problems"] = []
if c.get("merge") or re.match(r"^Merge (pull request|branch|remote-tracking branch|tag) ", s):
c["kind"] = "merge"; return c
m = re.match(r'^Revert "(.+)"$', s)
if m:
c["kind"] = "revert"; c["reverted_subject"] = m.group(1)
r = REVERTS.search(c.get("body", "")); c["reverted_hash"] = r.group(1) if r else None
return c
if re.match(r"^(fixup|squash|amend)! ", s) or re.match(r"^(wip|WIP)\b", s):
c["kind"] = "fixup"; return c
m = HEADER.match(s)
feet, breaking = parse_footers(c.get("body", ""))
c["footers"] = feet
if m:
t = m.group("type")
c.update(type=t.lower(), scope=m.group("scope"), bang=bool(m.group("bang")), desc=m.group("desc").strip(),
breaking_note=breaking, kind="cc")
if t != t.lower():
c["problems"].append(("low", "CC06", "type '%s' should be lower case" % t))
if c["type"] not in KNOWN:
alias = ALIASES.get(c["type"])
c["problems"].append(("medium", "CC02", "unknown type '%s'%s" % (t, (" - treated as '%s'" % alias) if alias else " - left out of the changelog")))
if alias:
c["type"] = alias
else:
c["kind"] = "unknown"
if c["type"] == "revert":
c["kind"] = "revert"; c["reverted_subject"] = c["desc"]
r = REVERTS.search(c.get("body", "")); c["reverted_hash"] = r.group(1) if r else None
return c
d = c["desc"]
if d.endswith("."):
c["problems"].append(("low", "CC06", "description ends with a period"))
if len(s) > 72:
c["problems"].append(("low", "CC06", "header is %d chars (keep <= 72)" % len(s)))
c["breaking"] = c["bang"] or breaking is not None
return c
lm = LOOSE.match(s)
c["kind"] = "nonconforming"
if lm and lm.group("desc"):
t = lm.group("type").lower(); t = ALIASES.get(t, t)
fixed = "%s%s%s: %s" % (t, "(%s)" % lm.group("scope").strip() if lm.group("scope") else "", "!" if lm.group("bang") else "", lm.group("desc").strip())
c["suggest"] = fixed
c["problems"].append(("medium", "CC01", "malformed header (spacing/colon) - suggest: %s" % fixed))
else:
c["suggest"] = guess_header(s)
c["problems"].append(("medium", "CC01", "not a Conventional Commit - suggest: %s" % c["suggest"]))
return c
def guess_header(s):
low = s.lower()
if re.match(r"^(fix|fixed|fixes|resolve|resolved|correct)", low) or "bug" in low or "typo" in low:
t = "docs" if re.search(r"readme|docs?\b|typo", low) else "fix"
elif re.match(r"^(add|added|implement|introduce|support|new)", low):
t = "feat"
elif re.match(r"^(update|bump|upgrade)", low) and re.search(r"dep|version|package|lock", low):
t = "chore(deps)"
elif re.match(r"^(remove|delete|drop)", low):
t = "refactor"
elif re.search(r"test", low):
t = "test"
else:
t = "chore"
if t == "fix":
s = re.sub(r"^(fixed|fixes|fix)\s+", "", s, flags=re.I)
d = re.sub(r"^(fixed|fixes|added|adds|updated|updates|removed|removes)\b", lambda m: {"fixed": "fix", "fixes": "fix", "added": "add", "adds": "add", "updated": "update", "updates": "update", "removed": "remove", "removes": "remove"}[m.group(1).lower()], s, flags=re.I)
d = d[0].lower() + d[1:] if d else d
return "%s: %s" % (t, d.rstrip("."))
def section_for(c):
d = c["desc"].lower()
if c["type"] == "security" or (c["type"] in ("fix", "deps") and re.search(r"\b(cve-\d|security|vulnerab|xss|csrf|injection|leak)", d)):
return "Security"
if re.search(r"\bdeprecat", d):
return "Deprecated"
if re.match(r"(remove|drop|delete)\b", d) and (c["type"] == "feat" or c.get("breaking")):
return "Removed"
if c["type"] == "feat":
return "Added"
if c["type"] == "fix":
return "Fixed"
if c["type"] in ("perf", "deps"):
return "Changed"
if c.get("breaking"):
return "Changed"
return None
# ---------------------------------------------------------------- analysis
def bump_version(current, level):
m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$", current or "")
if not m:
return None
a, b, c = map(int, m.groups())
if level == "major":
return "%d.0.0" % (a + 1) if a > 0 else "0.%d.0" % (b + 1)
if level == "minor":
return "%d.%d.0" % (a, b + 1)
if level == "patch":
return "%d.%d.%d" % (a, b, c + 1)
return None
def analyze(commits, current=None, include_internal=False):
for c in commits:
classify(c)
findings = []
# reverts: cancel out when the reverted commit is in range
by_hash = {c["hash"][:7]: c for c in commits if c.get("hash")}
by_subject = {}
for c in commits:
by_subject.setdefault(c["subject"], []).append(c)
for c in commits:
if c.get("kind") != "revert":
continue
tgt = None
if c.get("reverted_hash"):
tgt = by_hash.get(c["reverted_hash"][:7])
if tgt is None and c.get("reverted_subject") in by_subject:
tgt = by_subject[c["reverted_subject"]][-1]
if tgt is not None and tgt is not c:
tgt["reverted_by"] = c["hash"][:7] or c["subject"]
c["cancels"] = True
findings.append(("info", "CC04", "%s reverts %s '%s' in the same range - both left out" % (c["hash"][:7] or "?", tgt["hash"][:7] or "?", tgt["subject"])))
else:
c["cancels"] = False
findings.append(("medium", "CC04", "%s reverts '%s' from an earlier release - listed under Removed/Fixed; check the wording" % (c["hash"][:7] or "?", c.get("reverted_subject"))))
seen = {}
for c in commits:
if c.get("kind") == "cc":
key = (c["type"], (c.get("scope") or "").lower(), c["desc"].lower().rstrip("."))
if key in seen:
c["duplicate_of"] = seen[key]
findings.append(("low", "CC05", "%s duplicates %s ('%s', cherry-pick or re-applied?) - listed once" % (c["hash"][:7] or "?", seen[key] or "?", c["subject"])))
else:
seen[key] = c["hash"][:7]
sections = {s: [] for s in SECTIONS}
internal, breaking, skipped = [], [], []
level = None
rank = {"patch": 1, "minor": 2, "major": 3}
def up(l):
nonlocal level
if level is None or rank[l] > rank[level]:
level = l
for c in commits:
k = c.get("kind")
for sev, code, msg in c["problems"]:
findings.append((sev, code, "%s %s: %s" % (c["hash"][:7] or "-", c["subject"], msg)))
if k == "merge":
skipped.append((c, "merge commit")); continue
if k == "fixup":
skipped.append((c, "fixup/wip commit"))
findings.append(("medium", "CC07", "%s '%s' is a fixup/WIP commit on the release branch - squash it before tagging" % (c["hash"][:7] or "-", c["subject"])))
continue
if c.get("reverted_by") or (k == "revert" and c.get("cancels")):
skipped.append((c, "reverted" if c.get("reverted_by") else "revert (cancelled)")); continue
if c.get("duplicate_of"):
skipped.append((c, "duplicate")); continue
if k == "revert":
entry = {"text": "Revert: " + (c.get("reverted_subject") or c["subject"]), "hash": c["hash"][:7], "issues": c["issues"], "scope": None}
sections["Removed" if (c.get("reverted_subject") or "").startswith("feat") else "Fixed"].append(entry)
up("patch"); continue
if k in ("nonconforming", "unknown"):
skipped.append((c, "not a Conventional Commit")); continue
sec = section_for(c)
entry = {"text": c["desc"], "hash": c["hash"][:7], "issues": c["issues"], "scope": c.get("scope"),
"breaking": c.get("breaking", False), "type": c["type"]}
if c.get("breaking"):
up("major")
breaking.append({"hash": c["hash"][:7], "scope": c.get("scope"), "desc": c["desc"], "note": c.get("breaking_note")})
if not c.get("breaking_note"):
findings.append(("high", "CC03", "%s '%s' is breaking (!) but has no 'BREAKING CHANGE:' footer - add a migration note" % (c["hash"][:7] or "-", c["subject"])))
elif not c["bang"]:
findings.append(("info", "CC03", "%s '%s' is breaking via footer only - consider '!' in the header so it shows in one-line logs" % (c["hash"][:7] or "-", c["subject"])))
if sec:
sections[sec].append(entry)
if c["type"] == "feat":
up("minor")
elif c["type"] in ("fix", "perf", "security", "deps"):
up("patch")
else:
internal.append(entry)
if include_internal:
up("patch")
lost = [c for c, why in skipped if why == "not a Conventional Commit"]
lost_userfacing = [c for c in lost if re.match(r"^(feat|fix|perf|security)", c.get("suggest") or "")]
if lost_userfacing:
findings.append(("high", "CC08", "%d non-conforming commit(s) look user-facing (%s) and are NOT in the changelog - reword them or add the entries by hand" % (len(lost_userfacing), ", ".join(c["hash"][:7] or c["subject"][:20] for c in lost_userfacing))))
if not any(sections.values()) and not breaking:
findings.append(("info", "CC09", "no user-facing changes (only internal commits) - a release is optional"))
nxt = bump_version(current, level) if current else None
if current and level == "major" and re.match(r"^v?0\.", current):
findings.append(("info", "CC10", "version is 0.x: breaking changes bump the minor (%s); go to 1.0.0 once the API is stable" % nxt))
if current and not re.match(r"^v?\d+\.\d+\.\d+", current):
raise InputError("--current must look like 1.4.2, got %r" % current)
findings.sort(key=lambda f: (SEV_ORDER[f[0]], f[1]))
stats = {"commits": len(commits),
"conventional": sum(1 for c in commits if c.get("kind") in ("cc", "revert")),
"nonconforming": sum(1 for c in commits if c.get("kind") in ("nonconforming", "unknown")),
"merges": sum(1 for c in commits if c.get("kind") == "merge"),
"fixups": sum(1 for c in commits if c.get("kind") == "fixup")}
checkable = len(commits) - stats["merges"]
stats["conformance_pct"] = round(100.0 * stats["conventional"] / checkable, 1) if checkable else 100.0
return {"sections": sections, "internal": internal, "breaking": breaking, "skipped": skipped,
"level": level, "current": current, "next": nxt, "findings": findings, "stats": stats, "commits": commits}
# ---------------------------------------------------------------- rendering
def clean_text(t):
"""Drop a trailing '(#123)' (it is linked separately) and a trailing period."""
t = re.sub(r"\s*\((?:#\d+(?:,\s*)?)+\)\s*$", "", t).rstrip()
return t[:-1] if t.endswith(".") and not t.endswith("..") else t
def fmt_entry(e, repo_url=None):
t = clean_text(e["text"])
t = t[0].upper() + t[1:] if t else t
s = ("**%s:** " % e["scope"] if e.get("scope") else "") + t
if e.get("breaking"):
s = "**BREAKING** " + s
refs = []
for i in e.get("issues") or []:
refs.append("[#%s](%s/issues/%s)" % (i, repo_url, i) if repo_url else "#%s" % i)
if e.get("hash"):
refs.append("[%s](%s/commit/%s)" % (e["hash"], repo_url, e["hash"]) if repo_url else e["hash"])
return "- " + s + (" (" + ", ".join(refs) + ")" if refs else "")
def release_section(r, date=None, repo_url=None, tag_prefix="v", include_internal=False):
ver = r["next"] or "Unreleased"
head = "## [%s] - %s" % (ver, date or datetime.date.today().isoformat()) if r["next"] else "## [Unreleased]"
out = [head, ""]
if r["breaking"]:
out += ["### BREAKING CHANGES", ""]
for b in r["breaking"]:
d = clean_text(b["desc"])
line = "- " + ("**%s:** " % b["scope"] if b["scope"] else "") + d[0].upper() + d[1:]
line += " - " + b["note"] if b["note"] else " - MIGRATION NOTE MISSING"
out.append(line)
out.append("")
for s in SECTIONS:
items = sorted(r["sections"][s], key=lambda e: (not e.get("breaking"), (e.get("scope") or "~").lower()))
if items:
out += ["### " + s, ""] + [fmt_entry(e, repo_url) for e in items] + [""]
if include_internal and r["internal"]:
out += ["### Internal", ""] + [fmt_entry(e, repo_url) for e in r["internal"]] + [""]
if r["next"] and repo_url and r["current"]:
out.append("[%s]: %s/compare/%s%s...%s%s" % (ver, repo_url, tag_prefix, r["current"].lstrip("v"), tag_prefix, ver))
out.append("")
return "\n".join(out).rstrip() + "\n"
def insert_into(changelog_text, section, version):
if version and re.search(r"^## \[%s\]" % re.escape(version), changelog_text, re.M):
raise InputError("CHANGELOG already has a section for %s" % version)
lines = changelog_text.split("\n")
for i, l in enumerate(lines):
if re.match(r"^## \[Unreleased\]", l, re.I):
j = i + 1
while j < len(lines) and not lines[j].startswith("## "):
j += 1
leftover = [x for x in lines[i + 1:j] if x.strip()]
new = lines[:i + 1] + [""] + section.rstrip("\n").split("\n") + [""] + lines[j:]
return "\n".join(new), leftover
for i, l in enumerate(lines):
if l.startswith("## "):
return "\n".join(lines[:i] + section.rstrip("\n").split("\n") + [""] + lines[i:]), []
return changelog_text.rstrip("\n") + "\n\n" + section, []
def report(r, args):
st = r["stats"]
high = [f for f in r["findings"] if f[0] == "high"]
out = ["# Changelog build: %s" % args.commits, ""]
out.append("%d commits read: %d Conventional (%.1f%% of non-merge commits), %d non-conforming, %d merges, %d fixup/WIP." % (
st["commits"], st["conventional"], st["conformance_pct"], st["nonconforming"], st["merges"], st["fixups"]))
out.append("")
if r["current"]:
out.append("**Version: %s -> %s (%s bump%s)**" % (r["current"], r["next"] or r["current"], r["level"] or "no",
", driven by " + {"major": "breaking changes", "minor": "new features", "patch": "fixes/perf"}[r["level"]] if r["level"] else ""))
else:
out.append("**Suggested bump: %s** (pass --current X.Y.Z for the version number)" % (r["level"] or "none"))
out.append("")
out.append("**Verdict: %s**" % ("%d high finding(s) - fix before tagging" % len(high) if high else "ready to tag"))
out += ["", "## Release section (paste under the Unreleased heading of CHANGELOG.md)", "", "```markdown",
release_section(r, args.date, args.repo_url, args.tag_prefix, args.include_internal).rstrip(), "```", ""]
out += ["## Findings", ""]
if r["findings"]:
out += ["- **%s %s** %s" % (s.upper(), c, m) for s, c, m in r["findings"]]
else:
out.append("- none")
out += ["", "## Not in the changelog", "", "| Commit | Subject | Why |", "|---|---|---|"]
for c, why in r["skipped"]:
out.append("| %s | %s | %s |" % (c["hash"][:7] or "-", c["subject"].replace("|", "\\|"), why))
if r["internal"] and not args.include_internal:
out.append("| - | %d internal commit(s): %s | internal type (use --include-internal to list) |" % (
len(r["internal"]), ", ".join(sorted({e["type"] for e in r["internal"]}))))
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def run(txt, cur="1.4.2"):
return analyze(parse_commits(txt), cur)
r = run("a1b2c3d feat(api): add export endpoint\nb2c3d4e fix: handle empty list\n")
t("oneline parse + feat -> minor", r["next"] == "1.5.0" and len(r["sections"]["Added"]) == 1)
t("fix -> Fixed section", r["sections"]["Fixed"][0]["text"] == "handle empty list")
t("entry text drops trailing (#12) and period", fmt_entry({"text": "quote names. (#12)", "issues": ["12"], "hash": "abc1234"}) == "- Quote names (#12, abc1234)")
r = run("a1b2c3d fix: x\n")
t("fix only -> patch", r["next"] == "1.4.3")
r = run("a1b2c3d feat!: drop node 16\n")
t("bang breaking -> major", r["next"] == "2.0.0" and r["breaking"])
t("bang without footer is high CC03", any(f[1] == "CC03" and f[0] == "high" for f in r["findings"]))
t("feat removing -> Removed", r["sections"]["Removed"])
rec = "abc1234\x1ffeat(cli): new flag\x1fBody text.\n\nBREAKING CHANGE: --old is gone, use --new\x1e"
r = run(rec)
t("records format + footer breaking", r["breaking"] and r["breaking"][0]["note"].startswith("--old"))
t("footer-only breaking is info not high", not any(f[0] == "high" for f in r["findings"]))
t("0.x breaking -> minor", bump_version("0.9.3", "major") == "0.10.0")
r = run("a1b2c3d Fixed crash when saving\nb2c3d4e chore: tidy\n")
t("non-conforming flagged CC01", any(f[1] == "CC01" for f in r["findings"]))
t("suggest drops 'Fixed' verb", any("suggest: fix: crash when saving" in f[2] for f in r["findings"]))
t("user-facing non-conforming is high CC08", any(f[1] == "CC08" and f[0] == "high" for f in r["findings"]))
r = run("a1b2c3d feature: dark mode\n")
t("alias feature -> feat", r["sections"]["Added"] and any(f[1] == "CC02" for f in r["findings"]))
r = run("a1b2c3d fix:missing space\n")
t("malformed spacing suggestion", any("suggest: fix: missing space" in f[2] for f in r["findings"]))
log = ("commit 1111111aaaa\nAuthor: A <a@x>\nDate: Mon\n\n Revert \"feat: shiny\"\n\n This reverts commit 2222222bbbb.\n\n"
"commit 2222222bbbb\nAuthor: A <a@x>\nDate: Mon\n\n feat: shiny\n")
r = run(log)
t("revert in range cancels both", not any(r["sections"].values()) and any(f[1] == "CC04" for f in r["findings"]))
r = run("commit 3333333\nMerge: 1 2\nAuthor: A\nDate: x\n\n Merge pull request #5 from a/b\n\ncommit 4444444\nAuthor: A\nDate: x\n\n fixup! fix: y\n")
t("merge skipped, fixup flagged CC07", r["stats"]["merges"] == 1 and any(f[1] == "CC07" for f in r["findings"]))
r = run("a1b2c3d fix(auth): prevent token leak in logs (#41)\n")
t("security keyword -> Security + issue ref", r["sections"]["Security"] and r["sections"]["Security"][0]["issues"] == ["41"])
r = run("a1b2c3d feat(cfg): deprecate legacy yaml keys\n")
t("deprecate -> Deprecated", r["sections"]["Deprecated"])
r = run("a1b2c3d docs: readme\nb2c3d4e ci: cache\n")
t("internal only -> no bump + CC09", r["level"] is None and any(f[1] == "CC09" for f in r["findings"]))
r = run("a1b2c3d fix: same\nb2c3d4e fix: same\n")
t("duplicate listed once", len(r["sections"]["Fixed"]) == 1)
sec = release_section(run("a1b2c3d feat: x\n"), "2026-11-03", "https://github.com/o/r")
t("compare link + date", "## [1.5.0] - 2026-11-03" in sec and "compare/v1.4.2...v1.5.0" in sec)
new, left = insert_into("# Changelog\n\n## [Unreleased]\n\n## [1.4.2] - 2026-10-01\n- a\n", sec, "1.5.0")
t("insert after Unreleased", new.index("## [Unreleased]") < new.index("## [1.5.0]") < new.index("## [1.4.2]"))
try:
insert_into("## [1.5.0] - x\n", sec, "1.5.0"); ok = False
except InputError:
ok = True
t("insert refuses existing version", ok)
try:
run("a fix: x\n", cur="one"); ok = False
except InputError:
ok = True
t("bad --current rejected", ok)
for name, okk in tests:
print(("PASS " if okk else "FAIL ") + name)
n = sum(1 for _, okk in tests if okk)
print("%d/%d passed" % (n, len(tests)))
return 0 if n == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("commits", nargs="?")
ap.add_argument("--current", help="current released version, e.g. 1.4.2")
ap.add_argument("--date", help="release date (default today)")
ap.add_argument("--repo-url", help="https://github.com/owner/repo for commit, issue and compare links")
ap.add_argument("--tag-prefix", default="v")
ap.add_argument("--include-internal", action="store_true", help="also list refactor/docs/ci/chore/test commits")
ap.add_argument("--insert", metavar="CHANGELOG.md", help="print the changelog with the new section inserted (file is not modified)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.commits:
ap.error("commits file required (or --self-test)")
try:
if a.date and not re.match(r"^\d{4}-\d{2}-\d{2}$", a.date):
raise InputError("--date must be YYYY-MM-DD")
try:
text = sys.stdin.read() if a.commits == "-" else open(a.commits, encoding="utf-8").read()
except OSError as e:
raise InputError(str(e))
commits = parse_commits(text)
if not commits:
raise InputError("no commits found in %s" % a.commits)
r = analyze(commits, a.current, a.include_internal)
if a.insert:
try:
old = open(a.insert, encoding="utf-8").read()
except OSError as e:
raise InputError(str(e))
new, leftover = insert_into(old, release_section(r, a.date, a.repo_url, a.tag_prefix, a.include_internal), r["next"])
if leftover:
sys.stderr.write("note: %d hand-written line(s) under [Unreleased] were kept below the new section - merge them in\n" % len(leftover))
sys.stdout.write(new if new.endswith("\n") else new + "\n")
elif a.json:
j = {k: r[k] for k in ("sections", "internal", "breaking", "level", "current", "next", "stats")}
j["findings"] = [{"severity": s, "code": c, "message": m} for s, c, m in r["findings"]]
j["skipped"] = [{"hash": c["hash"][:7], "subject": c["subject"], "why": w} for c, w in r["skipped"]]
j["release_section"] = release_section(r, a.date, a.repo_url, a.tag_prefix, a.include_internal)
print(json.dumps(j, indent=2))
else:
print(report(r, a))
except InputError as e:
print("error: %s" % e, file=sys.stderr)
return 2
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Animate the step 2 Aegean village image as the first frame into a calm 6-second shot: the camera pushes in slowly toward the village and the stone stairway, ending close on a lit doorway with wall lanterns and bougainvillea, while the small ferry glides to the right, sunlight glitters on the sea, and more lamps glow warm. Step 3 of the Aegean Cliffside Village at Sunset workflow.
Start from the step 2 image "Whitewashed Aegean Cliffside Village with a Blue-Domed Chapel at Sunset" as the exact first frame, and animate it into a calm 6-second cinematic shot. Keep the white cube houses with blue doors on the left, the blue-domed chapel at the top-left, the winding stone stairway with magenta bougainvillea, the dark cliff, the calm deep blue sea, the sun just above the horizon, the colors, and the style exactly as in the image. The camera makes a slow, smooth push-in and gentle drift toward the village and the stone stairway, ending close on a lit doorway with warm wall lanterns and bougainvillea beside the steps. The small white ferry glides slowly to the right across the sea, leaving its thin wake. Sunlight glitters on the water, the clouds drift slightly, and more windows and lamps begin to glow warm in the village. The shot ends warm, peaceful, and romantic in the golden evening light. Natural, realistic motion, no people, no cuts, no text. Soft sound of gentle waves, a light sea breeze, and distant evening calm.Recently Updated
Prompt
A cute big gray elephant with huge ears and a small trunk standing happily in a beautiful green jungle, colorful trees and flowers, warm sunlight, friendly expression, 3D cartoon animation style, Pixar-like quality, cinematic lighting, vibrant colors, detailed, 16:9
Write a professional and humanised business plan requesting for funding to be submitted to bank of industry in Nigeria. Used Nigerian naira for all the analysis.business name will be Lekki 360 Grill & Lounge in Lekki Lagos as the proposed location
To write Chapter 3 methodology section for BIM based quality managament title.Written below are research questions and objectives. Research Questions RQ1: How are construction QA/QC processes currently implemented in building projects in Yangon Region, and what information-management challenges and organizational and technological conditions affect these processes? RQ2: Which BIM-enabled information-management functions are applicable to each stage of the construction QA/QC process, from quality planning to approval and closure? RQ3: Based on the findings of RQ1 and RQ2, how can construction QA/QC activities, BIM-enabled information-management functions, stakeholder responsibilities, information requirements, approval procedures, and traceability mechanisms be integrated into a BIM-based construction QA/QC process framework? RQ4: How do construction industry experts evaluate the relevance, completeness, practicality, and contextual suitability of the proposed framework for building projects in Yangon Region? Objectives of the Study The general objective of this study is to develop and evaluate a BIM-based construction QA/QC process framework for building projects in Yangon Region, Myanmar. The specific objectives of this study are to: 1. Identify current construction QA/QC processes, associated information-management challenges, and the organizational and technological conditions affecting them in building projects in Yangon Region. 2. Identify BIM-enabled information-management functions applicable to each stage of construction QA/QC: quality planning, inspection request, site inspection and testing, conformance assessment, NCR/defect recording, corrective action, reinspection, and approval and closure. 3. Develop a BIM-based construction QA/QC process framework integrating QA/QC activities, BIM-enabled information-management functions, stakeholder responsibilities, information requirements, approval procedures, and traceability mechanisms. 4. Evaluate the relevance, completeness, practicality, and contextual suitability of the proposed framework through expert assessment.
Opinionated conventions for structuring, testing and shipping Unity game projects.
# Unity Game Project taste of a shipping-focused game developer - Pin the Unity editor to one LTS version per project and upgrade it deliberately in its own branch. Confidence: 0.90 - Use Git with Git LFS for binary assets, plus a .gitattributes file and Unity's smart merge tool for scenes and prefabs. Confidence: 0.85 - Set asset serialization to Force Text and keep visible meta files so changes can be reviewed and merged. Confidence: 0.90 - Split code into assembly definitions per feature to keep compile times short and dependencies explicit. Confidence: 0.85 - Keep gameplay logic in plain C# classes that can be unit-tested, with MonoBehaviours acting as thin adapters. Confidence: 0.80 - Use ScriptableObjects for designer-tunable data such as weapon stats and levels, not hard-coded constants. Confidence: 0.85 - Avoid GameObject.Find and string-based lookups at runtime; wire references in the inspector or through a simple composition root. Confidence: 0.85 - Cache component references in Awake instead of calling GetComponent every frame. Confidence: 0.90 - Avoid allocations in Update loops and use object pooling for bullets, effects and other frequently spawned objects. Confidence: 0.90 - Profile on the slowest target device with the Unity Profiler before optimizing, not only in the editor. Confidence: 0.90 - Load content with Addressables instead of the Resources folder, so memory and download size stay under control. Confidence: 0.80 - Use the new Input System with action maps, so keyboard, gamepad and touch share the same gameplay code. Confidence: 0.80 - Prefer async loading and coroutines or async/await for long operations, and never block the main thread on I/O. Confidence: 0.85 - Keep prefabs small and use prefab variants and nested prefabs instead of duplicating whole hierarchies. Confidence: 0.80 - Run Edit Mode and Play Mode tests with the Unity Test Framework in CI on every pull request. Confidence: 0.75 - Build every target platform automatically in CI, such as with GameCI, so broken platform builds are caught the same day. Confidence: 0.80 - Treat compiler warnings and console errors as bugs, and keep the console clean in every scene. Confidence: 0.75 - Agree on fixed folder conventions like Art, Audio, Prefabs, Scenes and Scripts, and enforce them in review. Confidence: 0.70 - Make save data versioned and migrate old saves on load instead of breaking players' progress after an update. Confidence: 0.85 - Strip debug menus and cheats from release builds with scripting define symbols. Confidence: 0.80
Opinionated conventions for idempotent, testable and safe Ansible playbooks and roles.
# Ansible Playbook taste of a pragmatic infrastructure engineer - Organize work into roles with clear defaults, and keep playbooks thin, mostly a list of roles applied to host groups. Confidence: 0.90 - Use fully qualified collection names like ansible.builtin.copy in every task so modules are never ambiguous. Confidence: 0.85 - Pin collection and role versions in a requirements.yml file and install them in CI from that file only. Confidence: 0.90 - Write every task to be idempotent, so a second run reports zero changes on a converged host. Confidence: 0.95 - Prefer purpose-built modules over shell or command; when shell is unavoidable, set creates, removes or changed_when explicitly. Confidence: 0.90 - Give every task a clear, human-readable name that says what state it ensures, not which command it runs. Confidence: 0.80 - Keep variables in group_vars and host_vars, and avoid setting the same variable in more than two places. Confidence: 0.85 - Prefix role variables with the role name, such as nginx_worker_processes, to avoid collisions between roles. Confidence: 0.85 - Store secrets in Ansible Vault or an external secret manager lookup, and never commit them in plain text. Confidence: 0.95 - Use handlers for service restarts so services restart once at the end, only when their configuration actually changed. Confidence: 0.90 - Template configuration files with Jinja2 and validate them before replacing, using the validate option where the service supports it. Confidence: 0.85 - Run ansible-lint and yamllint in CI on every pull request. Confidence: 0.85 - Test roles with Molecule against disposable containers or VMs for each supported operating system. Confidence: 0.75 - Use --check and --diff before applying changes to production, and review the diff like a code change. Confidence: 0.85 - Roll out to production with serial batches and max_fail_percentage so a bad change stops before it reaches every host. Confidence: 0.85 - Keep a dynamic inventory from the cloud provider or CMDB instead of hand-edited host lists. Confidence: 0.75 - Use tags sparingly and consistently, mainly to run one role or one concern, not to build hidden execution paths. Confidence: 0.70 - Avoid ignore_errors; use failed_when with a precise condition or a block with rescue when failure is expected. Confidence: 0.85 - Set become only on the tasks that need root, not globally for the whole play. Confidence: 0.75 - Enable SSH pipelining and tune forks to cut run time, and measure with the profile_tasks callback before optimizing further. Confidence: 0.70 - Run playbooks from CI or a controlled runner such as AWX, not from personal laptops, so every change is logged. Confidence: 0.80
Opinionated conventions for reliable, testable and updatable embedded firmware on microcontrollers.
# Embedded Firmware taste of a reliability-minded firmware engineer - Keep hardware access behind a thin hardware abstraction layer so application logic can be built and unit-tested on the host machine. Confidence: 0.90 - Run unit tests on the host in CI with Unity or GoogleTest, and keep on-target tests for drivers and timing only. Confidence: 0.85 - Avoid dynamic memory allocation after startup; allocate buffers statically or from fixed-size pools sized at compile time. Confidence: 0.90 - Use a small RTOS such as Zephyr or FreeRTOS once there are more than two independent timing concerns, instead of growing a hand-written super loop. Confidence: 0.75 - Keep interrupt handlers short: capture the data, set a flag or push to a queue, and do the real work in a task. Confidence: 0.95 - Mark data shared with interrupts as volatile and guard multi-byte access with critical sections or atomics. Confidence: 0.90 - Enable the hardware watchdog in production builds and feed it only from a health check that confirms every critical task is alive. Confidence: 0.90 - Build with -Wall -Wextra -Werror and run a static analyzer such as cppcheck or clang-tidy on every pull request. Confidence: 0.85 - Pin the exact cross-compiler version in a container image or toolchain file so every build is reproducible. Confidence: 0.90 - Use fixed-width integer types like uint32_t everywhere hardware registers, protocols or storage formats are involved. Confidence: 0.90 - Track flash and RAM usage per build in CI and fail when either crosses an agreed budget. Confidence: 0.80 - Ship over-the-air updates with an A/B partition scheme, signed images and automatic rollback if the new image fails to boot. Confidence: 0.90 - Verify firmware signatures in the bootloader with a key the application cannot overwrite. Confidence: 0.85 - Lock debug ports such as JTAG and SWD on production units, with a documented process for authorized recovery. Confidence: 0.80 - Store a crash record with the program counter, fault registers and reset reason in retained RAM and report it on the next boot. Confidence: 0.85 - Use structured, compact logging with log levels and a ring buffer, and avoid printf in interrupt context. Confidence: 0.80 - Write wear-aware storage code: batch writes, use a flash file system like littlefs, and never rewrite the same sector in a tight loop. Confidence: 0.85 - Version configuration stored in flash and migrate it on boot, rather than assuming the layout never changes. Confidence: 0.80 - Design for low power from the start: sleep by default, wake on interrupts, and measure current draw with real hardware. Confidence: 0.75 - Run a hardware-in-the-loop test rig in CI for the main boards before releasing a firmware version. Confidence: 0.70 - Embed the git commit hash and build time in every image and expose them through a version command or register. Confidence: 0.85
Plans your reading day by day so you finish every book before its book club, course or exam date - from page or word counts (or audiobook length), your reading speed, the minutes you have each weekday, trips and busy days. Spreads pages evenly, keeps buffer days before each deadline and weekly catch-up days, stops at chapter ends, warns when a deadline is out of reach and re-plans from where you are. Includes a stdlib Python planner.
---
name: reading-plan-and-page-pace-planner
description: Plans your reading day by day so you finish every book before its book club, course or exam date - from page or word counts (or audiobook length), your reading speed, the minutes you have each weekday, trips and busy days. Spreads pages evenly, keeps buffer days before each deadline and weekly catch-up days, stops at chapter ends, warns when a deadline is out of reach and re-plans from where you are. Includes a stdlib Python planner.
---
# Reading Plan and Page Pace Planner
You help readers stop cramming 200 pages the night before book club. People know roughly how much time they have (half an hour on weekdays, more at the weekend) but not what that means in pages for a 245-page novel due in two weeks, a dense work book and an 880-page classic in the same month. You turn books, reading speed and free minutes into a concrete daily plan ("Tue: p. 1-27"), keep slack for real life, and say early and plainly when a deadline cannot be met and what would fix it.
## Files in this skill
- `scripts/reading_plan.py` - reads a plan JSON (books with pages, words or audiobook minutes, start page, difficulty, deadline, chapter starts; reading speed in words per minute or pages per hour; minutes per weekday; trips and exceptions; catch-up weekdays; buffer days); schedules earliest deadline first, spreads the load evenly (or as soon as possible), snaps daily stops to chapter ends, and prints a book summary, findings RP01-RP06, a day-by-day table with page ranges, weekly checkpoints and catch-up advice; `--from` + `--at "Title=page"` re-plans mid-way (Python 3.8+ standard library only)
- `references/reading-speed-and-estimates.md` - measuring your speed, words per page by book type, difficulty factors, audiobooks
- `references/pacing-and-catch-up.md` - buffers, catch-up days, what to do when you fall behind or a deadline is impossible
- `templates/reading-plan.md` - the plan to hand back (calendar-friendly)
- `examples/book-club.json` - November-December: a novel for a Thursday book club, a work book already started at p. 23, Middlemarch for a classics club, an audiobook with no deadline, Sunday catch-up days, a weekend trip and two unusual days
- `examples/book-club-plan.md` - the real planner output on that file
## Workflow
### 1. Collect the inputs
Ask for: each book with its page count (or word count; the edition matters), where they are now, the deadline and what it is (meeting, exam), whether it is easy or dense; their reading speed (or time a 10-minute test with `references/reading-speed-and-estimates.md`); realistic minutes per weekday, not ideal ones; known trips, late shifts and free days; which weekday should stay free as a catch-up day; and how many days before each meeting they want to be done (buffer, default 1-2).
### 2. Run the planner
```
python3 scripts/reading_plan.py examples/book-club.json
python3 scripts/reading_plan.py examples/book-club.json --mode asap
python3 scripts/reading_plan.py examples/book-club.json --from 2026-11-20 --at "Thinking in Systems=110"
python3 scripts/reading_plan.py plan.json --json
python3 scripts/reading_plan.py --self-test
```
Exit 0 = every deadline met, 1 = at least one book cannot be finished in time, 2 = bad input. Without a shell: minutes needed = pages x words per page / words per minute x difficulty; compare with the minutes available up to each finish-by date (deadline minus buffer, catch-up days excluded), earliest deadline first.
### 3. Read the result
- RP01 (high): a book does not fit before its deadline. The message gives the shortfall in pages and the extra minutes per reading day that would close it. When the plan is infeasible the planner reads at full pace before it, so earlier books finish early to free time.
- RP02 / RP04 (medium): the plan only works by eating the buffer, or uses over 90% of your time in its busiest stretch - one bad week breaks it.
- RP03 (low): days longer than the max session - split them. RP05/RP06 (info): finished books, close deadlines, audiobooks.
- Check the day table for heavy weekend days and the weekly checkpoints for "where should I be by Sunday".
### 4. Fix an impossible plan
Offer options in this order: start the long book now in parallel (move its deadline logic earlier), add 10-20 minutes on some days, use the catch-up days, switch part of it to the audiobook for commutes, skim or skip clearly marked sections, or accept arriving at the meeting part-way and say so. Re-run after each change. See `references/pacing-and-catch-up.md`.
### 5. Deliver
Fill `templates/reading-plan.md`: per-book summary, the week-by-week plan (dates and page ranges, ready for a calendar or a bookmark note), the catch-up rule, and the re-plan command. Offer a quick re-plan whenever they report their current page.
## Example result
On `examples/book-club.json` (240 wpm, 20-60 min a day, Sundays free for catch-up, 2 buffer days) *The Remains of the Day* is done on 12 Nov for the 19 Nov book club, stopping at chapter ends, and *Thinking in Systems* (dense, factor 1.3, from p. 23) on 27 Nov for the 1 Dec group. *Middlemarch* (316,000 words, about 22 hours) cannot be finished by the 17 Dec classics club: about 452 pages short, projected finish 11 Jan (RP01, high). Fix: about +17 minutes on each of the 42 reading days, or start it alongside the other two now. The 120-minute quiet Saturday is flagged as two sessions (RP03), and the Piranesi audiobook (6 h 40 at 1.25x) follows after Middlemarch.
## Rules
- Plan with the time people really have, and keep at least one buffer day before every meeting.
- Never quietly overfill days: if it does not fit, say which deadline fails, by how many pages, and what would fix it.
- Use the edition's page count; when only word counts are known, say that pages are estimates.
- Leave catch-up days empty in the plan - they are for falling behind, not for more reading.
- Reading should stay enjoyable: suggest skipping or switching formats before suggesting 2-hour sessions every day.
- Re-plan from the current page whenever the reader is behind or ahead; do not make them "catch up" to an old schedule.
FILE:references/reading-speed-and-estimates.md
# Reading speed and estimates
## Measure your speed (10 minutes)
1. Pick the actual book (or one like it), start at a chapter start, set a 10-minute timer and read normally.
2. Count the words on three full lines, divide by 3 = words per line; count the lines on a full page = lines per page.
3. words per page = words per line x lines per page; words per minute = pages read x words per page / 10.
Or simply time a few pages and use `"pages_per_hour"` instead of words per minute.
## Typical values
| Reader / material | Words per minute |
|---|---|
| Careful reading, dense non-fiction, second language | 150-200 |
| Average adult, fiction | 200-260 |
| Fast leisure reader | 300-400 |
Skimming is not reading: do not plan with speeds above 450.
| Book type | Words per page |
|---|---|
| Mass-market paperback | 300-350 |
| Trade paperback fiction | 250-300 |
| Hardcover non-fiction with notes | 350-450 |
| Large print, children's chapter books | 150-200 |
| E-book | use the word count (store page or tools that show it) and the default 275 words per page |
## Difficulty factor
`"difficulty"` multiplies reading time: 1.0 easy fiction, 1.2-1.4 dense non-fiction, classics with long sentences, technical books with diagrams, 1.5+ textbooks you take notes on, poetry, second-language reading.
## Page count vs word count
- Use the page count of the edition the reader holds; editions of the same book can differ by 30%.
- With both `"pages"` and `"words"`, the planner derives the words per page of that edition (Middlemarch 316,000 / 880 = 359).
- With only words, pages are estimates (words / words per page); say so in the plan.
## Audiobooks
- `"audio_minutes"` is the runtime, `"speed"` the playback speed: time needed = runtime / speed (400 min at 1.25x = 320 min).
- Audio fits commutes, chores and walks: say whether those minutes are extra or already counted in the daily minutes.
- Positions are shown as h:mm of the audio.
FILE:references/pacing-and-catch-up.md
# Pacing and catch-up
## How the planner schedules
- Earliest deadline first: the book with the nearest finish-by date (deadline minus buffer days) is read first; books without a deadline come last.
- Even mode (default): for each stretch up to the tightest deadline it uses the smallest equal share of your daily minutes that still finishes in time, so 30-minute days might become 20-minute days when there is room. After that deadline the next stretch is computed again.
- ASAP mode: uses all the minutes every day; books finish as early as possible.
- If a deadline cannot be met even at full pace, every day before it is planned at full pace and the book is flagged RP01.
- Daily stops move to a chapter end when one is within `chapter_snap_pages` (default 8) and a quarter of the day's pages.
## Buffers and catch-up days
- Buffer days (`buffer_days`, default 1) sit between the planned finish and the meeting: time to reread notes or absorb a bad week.
- Catch-up weekdays (`catch_up_days`, e.g. Sunday) get no planned reading. Use them only when behind; if you are on track they are free time.
- Exceptions (trips, late shifts, a quiet Saturday) override the weekday minutes for those dates.
## When you fall behind
1. Missed one day: read that day's pages on the next catch-up day, or add a third of them to each of the next three days.
2. Behind by more than two days: re-plan from today with `--from <today> --at "<Title>=<current page>"` instead of chasing the old plan.
3. Still not in time (RP01): pick one - start the long book in parallel now, add 10-20 minutes on a few days, use the audiobook for commutes, skim marked sections (prefaces, appendices, notes), or decide to arrive part-way and tell the group.
## Signs the plan is too tight
- RP04: over 90% of your time used in a stretch; any illness or late week breaks it.
- RP02: the book only fits by using the buffer days.
- Many days marked "2 sessions" (RP03): consider spreading reading across morning and evening on purpose.
## Book club tips
- Note the page or chapter the group will discuss if it is not the whole book, and use that as the book's `pages`.
- For long classics, ask the club whether the meeting covers the first half; plan two deadlines.
- Keep a sticky note in the book with the weekly checkpoints from the plan.
FILE:templates/reading-plan.md
# Reading plan: <name> (<start> to <last deadline>)
**Pace:** <wpm> words/min, about <pages> pages in a 30-minute session
**Time:** <minutes per weekday>; catch-up day(s): <day>; buffer: <n> day(s) before each meeting
**Verdict:** <every deadline is met | BOOK cannot be finished by DATE - see options>
## Books
| Book | Pages left | Time needed | Meeting / deadline | Finish by | Planned finish |
|---|---|---|---|---|---|
| <title> | <pp> | <h:mm> | <date - event> | <date> | <date> |
## Week by week
### Week of <Mon date>
- Mon: <Book p. a-b>
- Tue: <...>
- Sun: catch-up / free
**Checkpoint Sunday night:** <Book p. X>
## If a deadline does not fit
- Shortfall: <pages> pages of <book> by <date>
- Options: <+N min on reading days | start now in parallel | audiobook for commutes | skim chapters x-y | arrive part-way>
- Chosen: <...>
## Falling behind
- Missed a day? Use <catch-up day> or add <n> pages to the next three days.
- More than two days behind: re-plan from today (`--from <date> --at "<title>=<page>"`).
FILE:examples/book-club.json
{
"name": "Reading plan: Nov-Dec 2026 (two book clubs and a work book)",
"start": "2026-11-03",
"reader": {"words_per_minute": 240, "words_per_page": 280},
"minutes": {"mon": 30, "tue": 30, "wed": 20, "thu": 30, "fri": 25, "sat": 60, "sun": 45},
"catch_up_days": ["sun"],
"buffer_days": 2,
"max_session_minutes": 60,
"chapter_snap_pages": 8,
"exceptions": [
{"dates": "2026-11-13..2026-11-15", "minutes": 0, "note": "weekend trip"},
{"dates": "2026-11-26", "minutes": 15, "note": "late shift"},
{"dates": "2026-12-05", "minutes": 120, "note": "quiet Saturday"}
],
"books": [
{"title": "The Remains of the Day", "pages": 245, "words_per_page": 265, "deadline": "2026-11-19", "event": "Thursday book club",
"chapters": [1, 9, 45, 101, 125, 165, 212, 239]},
{"title": "Thinking in Systems", "pages": 218, "start_page": 23, "difficulty": 1.3, "deadline": "2026-12-01", "event": "team reading group"},
{"title": "Middlemarch", "words": 316000, "pages": 880, "deadline": "2026-12-17", "event": "classics club"},
{"title": "Piranesi (audiobook)", "audio_minutes": 400, "speed": 1.25}
]
}
FILE:examples/book-club-plan.md
# Reading plan: Nov-Dec 2026 (two book clubs and a work book)
Start 2026-11-03, even mode, 2 buffer day(s) before each deadline, catch-up days: sun. Planned reading: 36 h 44 min over 81 days.
**Verdict: 1 book(s) cannot be finished by their deadline - change the plan**
## Books
| Book | Left | Est. time | Deadline | Finish by | Planned finish | Days spare | Avg per reading day |
|---|---|---|---|---|---|---|---|
| The Remains of the Day | 245 pp | 4:31 h | 2026-11-19 (Thursday book club) | 2026-11-17 | 2026-11-12 | 7 | 27 pp |
| Thinking in Systems | 196 pp | 4:57 h | 2026-12-01 (team reading group) | 2026-11-29 | 2026-11-27 | 4 | 16 pp |
| Middlemarch | 880 pp | 21:57 h | 2026-12-17 (classics club) | 2026-12-15 | 2027-01-11 | -25 | 23 pp |
| Piranesi (audiobook) | 6:40 audio | 5:20 h | - | - | 2027-01-22 | - | 0:36 |
## Findings
- **HIGH RP01** Middlemarch cannot be finished by 2026-12-17 (classics club): about 452 pages short at your pace; projected finish 2027-01-11. Options: +17 min on each of the 42 reading days until then, read faster, skim or skip about 452 pages, or start it earlier
- **LOW RP03** 1 day(s) plan more than 60 minutes (e.g. 2026-12-05: 120 min) - split into two sessions
- **INFO RP06** Piranesi (audiobook) is an audiobook: the plan counts listening time at 1.25x speed; commutes and chores can cover it
## Day by day
| Date | Day | Minutes | Read | Note |
|---|---|---|---|---|
| 2026-11-03 | Tue | 30 | The Remains of the Day p. 1-27 (27 pp) | |
| 2026-11-04 | Wed | 19 | The Remains of the Day p. 28-44 (17 pp) | |
| 2026-11-05 | Thu | 30 | The Remains of the Day p. 45-71 (27 pp) | |
| 2026-11-06 | Fri | 25 | The Remains of the Day p. 72-94 (23 pp) | |
| 2026-11-07 | Sat | 60 | The Remains of the Day p. 95-148 (54 pp) | |
| 2026-11-08 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-11-09 | Mon | 30 | The Remains of the Day p. 149-175 (27 pp) | |
| 2026-11-10 | Tue | 30 | The Remains of the Day p. 176-202 (27 pp) | |
| 2026-11-11 | Wed | 20 | The Remains of the Day p. 203-221 (19 pp) | |
| 2026-11-12 | Thu | 30 | The Remains of the Day p. 222-245 (24 pp); Thinking in Systems p. 23-24 (2 pp) | |
| 2026-11-13 | Fri | - | - | weekend trip |
| 2026-11-14 | Sat | - | - | weekend trip |
| 2026-11-15 | Sun | - | - | weekend trip |
| 2026-11-16 | Mon | 30 | Thinking in Systems p. 25-44 (20 pp) | |
| 2026-11-17 | Tue | 30 | Thinking in Systems p. 45-64 (20 pp) | finish-by The Remains of the Day |
| 2026-11-18 | Wed | 20 | Thinking in Systems p. 65-77 (13 pp) | |
| 2026-11-19 | Thu | 30 | Thinking in Systems p. 78-97 (20 pp) | DEADLINE The Remains of the Day - Thursday book club |
| 2026-11-20 | Fri | 25 | Thinking in Systems p. 98-113 (16 pp) | |
| 2026-11-21 | Sat | 60 | Thinking in Systems p. 114-153 (40 pp) | |
| 2026-11-22 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-11-23 | Mon | 30 | Thinking in Systems p. 154-172 (19 pp) | |
| 2026-11-24 | Tue | 30 | Thinking in Systems p. 173-192 (20 pp) | |
| 2026-11-25 | Wed | 20 | Thinking in Systems p. 193-205 (13 pp) | |
| 2026-11-26 | Thu | 15 | Thinking in Systems p. 206-215 (10 pp) | late shift |
| 2026-11-27 | Fri | 25 | Thinking in Systems p. 216-218 (3 pp); Middlemarch p. 1-14 (14 pp) | |
| 2026-11-28 | Sat | 60 | Middlemarch p. 15-54 (40 pp) | |
| 2026-11-29 | Sun | - | - | catch-up / spare (45 min available); finish-by Thinking in Systems |
| 2026-11-30 | Mon | 30 | Middlemarch p. 55-74 (20 pp) | |
| 2026-12-01 | Tue | 30 | Middlemarch p. 75-94 (20 pp) | DEADLINE Thinking in Systems - team reading group |
| 2026-12-02 | Wed | 20 | Middlemarch p. 95-107 (13 pp) | |
| 2026-12-03 | Thu | 30 | Middlemarch p. 108-128 (21 pp) | |
| 2026-12-04 | Fri | 25 | Middlemarch p. 129-144 (16 pp) | |
| 2026-12-05 | Sat | 120 | Middlemarch p. 145-224 (80 pp) | quiet Saturday; 2 sessions |
| 2026-12-06 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-07 | Mon | 30 | Middlemarch p. 225-244 (20 pp) | |
| 2026-12-08 | Tue | 30 | Middlemarch p. 245-265 (21 pp) | |
| 2026-12-09 | Wed | 20 | Middlemarch p. 266-278 (13 pp) | |
| 2026-12-10 | Thu | 30 | Middlemarch p. 279-298 (20 pp) | |
| 2026-12-11 | Fri | 25 | Middlemarch p. 299-315 (17 pp) | |
| 2026-12-12 | Sat | 60 | Middlemarch p. 316-355 (40 pp) | |
| 2026-12-13 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-14 | Mon | 30 | Middlemarch p. 356-375 (20 pp) | |
| 2026-12-15 | Tue | 30 | Middlemarch p. 376-395 (20 pp) | finish-by Middlemarch |
| 2026-12-16 | Wed | 20 | Middlemarch p. 396-408 (13 pp) | |
| 2026-12-17 | Thu | 30 | Middlemarch p. 409-428 (20 pp) | DEADLINE Middlemarch - classics club |
| 2026-12-18 | Fri | 25 | Middlemarch p. 429-445 (17 pp) | |
| 2026-12-19 | Sat | 60 | Middlemarch p. 446-485 (40 pp) | |
| 2026-12-20 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-21 | Mon | 30 | Middlemarch p. 486-505 (20 pp) | |
| 2026-12-22 | Tue | 30 | Middlemarch p. 506-525 (20 pp) | |
| 2026-12-23 | Wed | 20 | Middlemarch p. 526-539 (14 pp) | |
| 2026-12-24 | Thu | 30 | Middlemarch p. 540-559 (20 pp) | |
| 2026-12-25 | Fri | 25 | Middlemarch p. 560-575 (16 pp) | |
| 2026-12-26 | Sat | 60 | Middlemarch p. 576-615 (40 pp) | |
| 2026-12-27 | Sun | - | - | catch-up / spare (45 min available) |
| 2026-12-28 | Mon | 30 | Middlemarch p. 616-635 (20 pp) | |
| 2026-12-29 | Tue | 30 | Middlemarch p. 636-656 (21 pp) | |
| 2026-12-30 | Wed | 20 | Middlemarch p. 657-669 (13 pp) | |
| 2026-12-31 | Thu | 30 | Middlemarch p. 670-689 (20 pp) | |
| 2027-01-01 | Fri | 25 | Middlemarch p. 690-706 (17 pp) | |
| 2027-01-02 | Sat | 60 | Middlemarch p. 707-746 (40 pp) | |
| 2027-01-03 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-04 | Mon | 30 | Middlemarch p. 747-766 (20 pp) | |
| 2027-01-05 | Tue | 30 | Middlemarch p. 767-786 (20 pp) | |
| 2027-01-06 | Wed | 20 | Middlemarch p. 787-799 (13 pp) | |
| 2027-01-07 | Thu | 30 | Middlemarch p. 800-819 (20 pp) | |
| 2027-01-08 | Fri | 25 | Middlemarch p. 820-836 (17 pp) | |
| 2027-01-09 | Sat | 60 | Middlemarch p. 837-876 (40 pp) | |
| 2027-01-10 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-11 | Mon | 30 | Middlemarch p. 877-880 (4 pp); Piranesi (audiobook) 0:00-0:30 | |
| 2027-01-12 | Tue | 30 | Piranesi (audiobook) 0:30-1:08 | |
| 2027-01-13 | Wed | 20 | Piranesi (audiobook) 1:08-1:33 | |
| 2027-01-14 | Thu | 30 | Piranesi (audiobook) 1:33-2:10 | |
| 2027-01-15 | Fri | 25 | Piranesi (audiobook) 2:10-2:41 | |
| 2027-01-16 | Sat | 60 | Piranesi (audiobook) 2:41-3:56 | |
| 2027-01-17 | Sun | - | - | catch-up / spare (45 min available) |
| 2027-01-18 | Mon | 30 | Piranesi (audiobook) 3:56-4:34 | |
| 2027-01-19 | Tue | 30 | Piranesi (audiobook) 4:34-5:11 | |
| 2027-01-20 | Wed | 20 | Piranesi (audiobook) 5:11-5:36 | |
| 2027-01-21 | Thu | 30 | Piranesi (audiobook) 5:36-6:14 | |
| 2027-01-22 | Fri | 21 | Piranesi (audiobook) 6:14-6:40 | |
## Weekly checkpoints (where you should be by Sunday night)
- 2026-11-08: The Remains of the Day p. 148
- 2026-11-15: Thinking in Systems p. 24
- 2026-11-22: Thinking in Systems p. 153
- 2026-11-29: Middlemarch p. 54
- 2026-12-06: Middlemarch p. 224
- 2026-12-13: Middlemarch p. 355
- 2026-12-20: Middlemarch p. 485
- 2026-12-27: Middlemarch p. 615
- 2027-01-03: Middlemarch p. 746
- 2027-01-10: Middlemarch p. 876
- 2027-01-17: Piranesi (audiobook) 3:56
## If you fall behind
- The Remains of the Day: a missed day costs about 27 pages. Make them up on the spare day(s) 2026-11-08, or add 10 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "The Remains of the Day=<page>"`.
- Thinking in Systems: a missed day costs about 16 pages. Make them up on the spare day(s) 2026-11-22, 2026-11-29, or add 6 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "Thinking in Systems=<page>"`.
- Middlemarch: a missed day costs about 23 pages. Make them up on the spare day(s) 2026-11-29, 2026-12-06, 2026-12-13, or add 8 pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at "Middlemarch=<page>"`.
FILE:scripts/reading_plan.py
#!/usr/bin/env python3
"""Reading plan and page pace planner (stdlib only, Python 3.8+).
Turns a list of books (pages, word counts or audiobook length), your reading speed and the
minutes you have each day into a day-by-day plan with page ranges that meets every deadline
(book club, course, exam), keeps buffer days before each deadline and catch-up days in the
week, spreads the load evenly and tells you what to do if you fall behind.
Usage:
reading_plan.py PLAN.json [--mode even|asap] [--from YYYY-MM-DD] [--at "Title=page"]... [--json]
reading_plan.py --self-test
Exit: 0 = every deadline is met, 1 = at least one book cannot be finished by its deadline,
2 = bad input.
"""
import argparse, datetime, json, math, re, sys
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
class InputError(Exception):
pass
def pdate(s, what):
try:
return datetime.date.fromisoformat(str(s))
except ValueError:
raise InputError("%s must be YYYY-MM-DD, got %r" % (what, s))
def expand_dates(spec):
spec = str(spec)
if ".." in spec:
a, b = spec.split("..", 1)
a, b = pdate(a.strip(), "date range"), pdate(b.strip(), "date range")
if b < a:
raise InputError("date range %s ends before it starts" % spec)
return [a + datetime.timedelta(days=i) for i in range((b - a).days + 1)]
return [pdate(spec.strip(), "date")]
# ---------------------------------------------------------------- model
def load(cfg, start_override=None, at=None):
if not isinstance(cfg, dict) or not isinstance(cfg.get("books"), list) or not cfg["books"]:
raise InputError("plan needs a non-empty 'books' list")
reader = cfg.get("reader") or {}
wpm = float(reader.get("words_per_minute", 230))
wpp_default = float(reader.get("words_per_page", 275))
pph = reader.get("pages_per_hour")
if wpm <= 0 or wpp_default <= 0:
raise InputError("words_per_minute and words_per_page must be positive")
start = pdate(start_override or cfg.get("start") or datetime.date.today().isoformat(), "start")
mins = cfg.get("minutes")
if isinstance(mins, (int, float)):
mins = {d: mins for d in DAYS}
if not isinstance(mins, dict):
raise InputError("'minutes' must be a number or {mon: .., tue: ..}")
mins = {k.lower()[:3]: float(v) for k, v in mins.items()}
bad = set(mins) - set(DAYS)
if bad:
raise InputError("unknown weekday key(s) in minutes: %s" % ", ".join(sorted(bad)))
catch = {d.lower()[:3] for d in cfg.get("catch_up_days", [])}
exc = {}
for e in cfg.get("exceptions", []):
for d in expand_dates(e.get("dates") or e.get("date")):
exc[d] = (float(e.get("minutes", 0)), e.get("note", ""))
books = []
at = at or {}
for i, b in enumerate(cfg["books"], 1):
t = b.get("title") or "Book %d" % i
if "audio_minutes" in b:
speed = float(b.get("speed", 1.0))
total = float(b["audio_minutes"])
unit_min = 1.0 / speed # minutes of your time per minute of audio
units, kind = total, "audio"
done = float(at.get(t, b.get("start_minute", 0)))
else:
wpp = float(b.get("words_per_page") or (float(b["words"]) / float(b["pages"]) if b.get("words") and b.get("pages") else wpp_default))
pages = float(b["pages"]) if b.get("pages") else (float(b["words"]) / wpp if b.get("words") else None)
if not pages or pages <= 0:
raise InputError("book %r needs pages, words or audio_minutes" % t)
diff = float(b.get("difficulty", 1.0))
if pph and not b.get("words"):
unit_min = 60.0 / float(pph) * diff
else:
unit_min = wpp / wpm * diff
units, kind = round(pages), "pages"
sp = at.get(t, b.get("start_page", 1))
done = max(0.0, float(sp) - 1)
dl = pdate(b["deadline"], "deadline of %r" % t) if b.get("deadline") else None
chapters = sorted(int(c) for c in b.get("chapters", []))
books.append({"title": t, "units": float(units), "kind": kind, "unit_min": unit_min, "done": min(done, float(units)),
"deadline": dl, "event": b.get("event", ""), "chapters": chapters, "order": i})
buf = int(cfg.get("buffer_days", 1))
return {"start": start, "mins": mins, "catch": catch, "exc": exc, "books": books, "buffer": buf,
"max_session": float(cfg.get("max_session_minutes", 0) or 0), "snap": int(cfg.get("chapter_snap_pages", 8)),
"name": cfg.get("name", "Reading plan"), "wpm": wpm}
def capacity(m, d):
"""(planned minutes, available minutes incl. catch-up, note)."""
if d in m["exc"]:
v, note = m["exc"][d]
return v, v, note or ("no reading" if v == 0 else "")
wd = DAYS[d.weekday()]
v = m["mins"].get(wd, 0.0)
if wd in m["catch"]:
return 0.0, v, "catch-up day"
return v, v, ""
def plan(m, mode="even"):
F = []
books = [b for b in m["books"]]
for b in books:
b["need"] = (b["units"] - b["done"]) * b["unit_min"]
if b["need"] <= 0:
F.append(("info", "RP05", "%s is already finished - left out" % b["title"]))
b["target"] = None
if b["deadline"] and b["deadline"] < m["start"]:
if b["need"] > 0:
F.append(("medium", "RP05", "%s: deadline %s has already passed - planned without a deadline" % (b["title"], b["deadline"])))
b["deadline"] = None
if b["deadline"]:
b["target"] = b["deadline"] - datetime.timedelta(days=m["buffer"])
if b["target"] < m["start"]:
b["target"] = m["start"]
F.append(("medium", "RP05", "%s: deadline %s is too close for %d buffer day(s) - planning to the deadline" % (b["title"], b["deadline"], m["buffer"])))
todo = [b for b in books if b["need"] > 0]
dated = sorted([b for b in todo if b["target"]], key=lambda b: (b["target"], b["order"]))
undated = [b for b in todo if not b["target"]]
seq = dated + undated
last = max([b["deadline"] for b in dated] or [m["start"]])
# horizon: enough days for everything at full capacity, plus slack
weekly = sum(m["mins"].get(d, 0) for d in DAYS if d not in m["catch"]) or 1
total_need = sum(b["need"] for b in seq)
horizon_end = max(last, m["start"]) + datetime.timedelta(days=int(total_need / weekly * 7) + 60)
days, d = [], m["start"]
while d <= horizon_end:
p, a, note = capacity(m, d)
days.append({"date": d, "cap": p, "avail": a, "note": note, "planned": 0.0, "items": []})
d += datetime.timedelta(days=1)
idx = {x["date"]: i for i, x in enumerate(days)}
# load factor per segment (even mode): smallest uniform share of capacity that meets every target
load = [1.0] * len(days)
if mode == "even" and dated:
seg_start, k0 = 0, 0
while k0 < len(dated):
best, bk = 0.0, k0
cum = 0.0
for k in range(k0, len(dated)):
cum += dated[k]["need"]
ti = idx[dated[k]["target"]]
cap = sum(x["cap"] for x in days[seg_start:ti + 1])
r = cum / cap if cap > 0 else float("inf")
if r >= best:
best, bk = r, k
L = min(best * 1.03, 1.0) # 3% headroom for chapter-end stops and rounding
end = idx[dated[bk]["target"]]
for i in range(seg_start, end + 1):
load[i] = L
seg_start, k0 = end + 1, bk + 1
# after the last deadline: undated books at the average load of the plan so far (at least 0.5)
tail = max(0.5, max(load[:seg_start] or [1.0]))
for i in range(seg_start, len(days)):
load[i] = tail if undated else 1.0
# allocate (earliest deadline first), tracking each book's position
qi = 0
pos = {b["title"]: b["done"] for b in seq}
for i, day in enumerate(days):
budget = day["cap"] * load[i]
while budget > 1e-9 and qi < len(seq):
b = seq[qi]
remaining = (b["units"] - pos[b["title"]]) * b["unit_min"]
use = min(budget, remaining)
before = pos[b["title"]]
after = before + use / b["unit_min"]
if after > b["units"] - 1e-6:
after = b["units"]
elif b["kind"] == "pages":
snapped = snap(after, b, m["snap"], use / b["unit_min"])
if snapped != after:
after = snapped
use = (after - before) * b["unit_min"]
budget = use # stop at the chapter end: the day ends here
pos[b["title"]] = after
day["items"].append({"title": b["title"], "from": before, "to": after, "kind": b["kind"], "minutes": use})
day["planned"] += use
budget -= use
if after >= b["units"] - 1e-6:
b["finish"] = day["date"]; qi += 1
elif budget <= 1e-9 or use <= 1e-9:
break
if qi >= len(seq):
break
days = [x for x in days if x["date"] <= max([b.get("finish", m["start"]) for b in seq] + [last])]
for b in seq:
b.setdefault("finish", None)
# findings
for b in dated:
f = b["finish"]
if f is None or f > b["deadline"]:
avail = [x for x in days if x["date"] <= b["deadline"]]
share = sum(it["minutes"] for x in avail for it in x["items"] if it["title"] == b["title"])
short_min = b["need"] - share
# also count catch-up/buffer time that would be available up to the deadline
reading_days = sum(1 for x in avail if x["avail"] > 0) or 1
short_units = short_min / b["unit_min"]
msg = "%s cannot be finished by %s%s: about %d %s short at your pace; projected finish %s. Options: +%d min on each of the %d reading days until then, read faster, skim or skip about %d %s, or start it earlier" % (
b["title"], b["deadline"], " (%s)" % b["event"] if b["event"] else "", math.ceil(short_units), "pages" if b["kind"] == "pages" else "audio minutes",
f or "beyond the plan", math.ceil(short_min / reading_days), reading_days, math.ceil(short_units), "pages" if b["kind"] == "pages" else "audio minutes")
F.append(("high", "RP01", msg))
elif f > b["target"]:
F.append(("medium", "RP02", "%s finishes %s, inside the %d buffer day(s) before %s - no room for a missed day" % (b["title"], f, m["buffer"], b["deadline"])))
if mode == "even":
peak = max(load) if load else 0
if 0.9 <= peak <= 1.0 and not any(f[1] == "RP01" for f in F):
F.append(("medium", "RP04", "the plan uses %d%% of your planned reading time in its busiest stretch - one bad week will push you into the buffer" % round(peak * 100)))
if m["max_session"]:
long = [x for x in days if x["planned"] > m["max_session"] + 0.5]
if long:
F.append(("low", "RP03", "%d day(s) plan more than %d minutes (e.g. %s: %d min) - split into two sessions" % (
len(long), m["max_session"], long[0]["date"], round(long[0]["planned"]))))
for b in seq:
if b["kind"] == "audio":
F.append(("info", "RP06", "%s is an audiobook: the plan counts listening time at %sx speed; commutes and chores can cover it" % (b["title"], ("%.2f" % (1 / b["unit_min"])).rstrip("0").rstrip("."))))
F.sort(key=lambda f: (SEV[f[0]], f[1]))
return {"days": days, "books": seq, "all_books": books, "findings": F, "load": load}
def snap(after, b, snap_pages, day_units):
"""Move a day's stopping point to a chapter end if one is close (within snap_pages and 25% of the day)."""
if not b["chapters"] or snap_pages <= 0:
return after
lim = min(snap_pages, max(1.0, day_units * 0.25))
ends = [c - 1 for c in b["chapters"] if c - 1 > 0]
best = None
for e in ends:
if abs(e - after) <= lim and e > after - day_units + 0.5 and (best is None or abs(e - after) < abs(best - after)):
best = e
return float(best) if best is not None else after
# ---------------------------------------------------------------- rendering
def fmt_item(it):
if it["kind"] == "audio":
return "%s %s-%s" % (it["title"], hm(it["from"]), hm(it["to"]))
a = int(round(it["from"])) + 1
z = int(round(it["to"]))
if z < a:
z = a
return "%s p. %d-%d (%d pp)" % (it["title"], a, z, z - a + 1)
def hm(x):
x = int(round(x))
return "%d:%02d" % (x // 60, x % 60)
def checkpoints(r):
out = []
pos = {}
for x in r["days"]:
for it in x["items"]:
pos[it["title"]] = it
if x["date"].weekday() == 6 and pos: # Sundays
out.append((x["date"], "; ".join("%s %s" % (t, ("p. %d" % round(it["to"]) if it["kind"] == "pages" else hm(it["to"]))) for t, it in pos.items() if it["to"] < next(b["units"] for b in r["books"] if b["title"] == t) - 1e-6) or "all current books done"))
return out
def report(r, m, mode):
hi = [f for f in r["findings"] if f[0] == "high"]
out = ["# %s" % m["name"], ""]
tot_min = sum(x["planned"] for x in r["days"])
out.append("Start %s, %s mode, %d buffer day(s) before each deadline, catch-up days: %s. Planned reading: %d h %02d min over %d days." % (
m["start"], mode, m["buffer"], ", ".join(sorted(m["catch"], key=DAYS.index)) or "none", tot_min // 60, tot_min % 60, len(r["days"])))
out += ["", "**Verdict: %s**" % ("%d book(s) cannot be finished by their deadline - change the plan" % len(hi) if hi else "every deadline is met"), ""]
out += ["## Books", "", "| Book | Left | Est. time | Deadline | Finish by | Planned finish | Days spare | Avg per reading day |", "|---|---|---|---|---|---|---|---|"]
for b in r["all_books"]:
left = b["units"] - b["done"]
rd = [x for x in r["days"] if any(it["title"] == b["title"] for it in x["items"])]
avg = left / len(rd) if rd else 0
spare = (b["deadline"] - b["finish"]).days if b.get("finish") and b["deadline"] else None
out.append("| %s | %s | %s | %s | %s | %s | %s | %s |" % (
b["title"], ("%d pp" % round(left)) if b["kind"] == "pages" else hm(left) + " audio", hm(b["need"]) + " h",
"%s %s" % (b["deadline"], "(%s)" % b["event"] if b["event"] else "") if b["deadline"] else "-",
b["target"] or "-", b.get("finish") or ("done" if b["need"] <= 0 else "NOT IN TIME"),
"-" if spare is None else spare, ("%.0f pp" % avg) if b["kind"] == "pages" else hm(avg)))
out += ["", "## Findings", ""]
out += ["- **%s %s** %s" % (s.upper(), c, msg) for s, c, msg in r["findings"]] or ["- none"]
out += ["", "## Day by day", "", "| Date | Day | Minutes | Read | Note |", "|---|---|---|---|---|"]
events = {}
for b in r["all_books"]:
if b["deadline"]:
events.setdefault(b["deadline"], []).append("DEADLINE %s%s" % (b["title"], " - " + b["event"] if b["event"] else ""))
if b["target"] and b["target"] != b["deadline"]:
events.setdefault(b["target"], []).append("finish-by %s" % b["title"])
for x in r["days"]:
note = [x["note"]] if x["note"] else []
if x["note"] == "catch-up day" or (x["planned"] < 0.5 and x["avail"] > 0):
note = ["catch-up / spare (%d min available)" % x["avail"]] if x["avail"] > 0 else note
note += events.get(x["date"], [])
if m["max_session"] and x["planned"] > m["max_session"] + 0.5:
note.append("2 sessions")
out.append("| %s | %s | %s | %s | %s |" % (x["date"], x["date"].strftime("%a"), round(x["planned"]) if x["planned"] >= 0.5 else "-",
"; ".join(fmt_item(it) for it in x["items"]) or "-", "; ".join(note)))
cps = checkpoints(r)
if cps:
out += ["", "## Weekly checkpoints (where you should be by Sunday night)", ""]
out += ["- %s: %s" % (d, s) for d, s in cps]
out += ["", "## If you fall behind", ""]
for b in r["books"]:
if b["kind"] != "pages" or not b["deadline"]:
continue
rd = [x for x in r["days"] if any(it["title"] == b["title"] for it in x["items"])]
if not rd:
continue
day_pp = sum(it["to"] - it["from"] for x in rd for it in x["items"] if it["title"] == b["title"]) / len(rd)
spare = [x for x in r["days"] if x["date"] <= b["deadline"] and x["date"] >= rd[0]["date"] and x["avail"] > 0 and x["planned"] < 0.5]
out.append("- %s: a missed day costs about %d pages. Make them up on %s, or add %d pages to each of the next 3 reading days. Re-plan any time with `--from <date> --at \"%s=<page>\"`." % (
b["title"], round(day_pp), ("the spare day(s) " + ", ".join(str(x["date"]) for x in spare[:3])) if spare else "no spare day before the deadline", math.ceil(day_pp / 3), b["title"]))
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def run(cfg, mode="even", **kw):
m = load(cfg, **kw)
return plan(m, mode), m
base = {"start": "2026-11-02", "reader": {"words_per_minute": 250, "words_per_page": 250}, "minutes": 30, "buffer_days": 0}
r, m = run(dict(base, books=[{"title": "A", "pages": 60, "deadline": "2026-11-08"}]), "asap")
t("asap: 30 pages/day at 1 page/min finishes day 2", r["books"][0]["finish"] == datetime.date(2026, 11, 3))
r, m = run(dict(base, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]))
t("even: spread over the week (~10 min/day + 3% headroom)", r["books"][0]["finish"] >= datetime.date(2026, 11, 7) and 10 <= r["days"][0]["planned"] <= 10.31)
r, m = run(dict(base, books=[{"title": "A", "pages": 300, "deadline": "2026-11-08"}]))
t("infeasible -> RP01 high", any(f[1] == "RP01" and f[0] == "high" for f in r["findings"]))
t("RP01 suggests extra minutes", any("+13 min" in f[2] for f in r["findings"] if f[1] == "RP01"))
r, m = run(dict(base, buffer_days=2, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]))
t("buffer days: finish 2 days early", r["books"][0]["finish"] == datetime.date(2026, 11, 6))
r, m = run(dict(base, catch_up_days=["sun"], books=[{"title": "A", "pages": 60, "deadline": "2026-11-08"}]))
t("catch-up Sunday gets no planned reading", r["days"][-1]["planned"] == 0 and r["books"][0]["finish"] == datetime.date(2026, 11, 7))
r, m = run(dict(base, exceptions=[{"dates": "2026-11-03..2026-11-04", "minutes": 0, "note": "trip"}], books=[{"title": "A", "pages": 50, "deadline": "2026-11-08"}]))
t("exception range has no reading", all(x["planned"] == 0 for x in r["days"][1:3]))
two = [{"title": "Late", "pages": 30, "deadline": "2026-11-20"}, {"title": "Soon", "pages": 60, "deadline": "2026-11-04"}]
r, m = run(dict(base, books=two))
t("earliest deadline first", r["days"][0]["items"][0]["title"] == "Soon")
t("tight segment then lighter load", r["load"][0] > 0.6 and r["load"][5] < 0.2)
r, m = run(dict(base, books=[{"title": "A", "pages": 70, "deadline": "2026-11-08"}]), at={"A": 36})
t("--at resumes from page", abs(sum(x["planned"] for x in r["days"]) - 35) < 0.01)
r, m = run(dict(base, books=[{"title": "W", "words": 25000, "deadline": "2026-11-08"}]))
t("word count -> pages via words_per_page", r["all_books"][0]["units"] == 100)
r, m = run(dict(base, books=[{"title": "H", "pages": 70, "difficulty": 1.5, "deadline": "2026-11-08"}]))
t("difficulty scales time", abs(r["books"][0]["need"] - 105) < 0.01)
r, m = run(dict(base, books=[{"title": "Au", "audio_minutes": 300, "speed": 1.5, "deadline": "2026-11-08"}]))
t("audiobook at 1.5x = 200 min", abs(r["books"][0]["need"] - 200) < 0.01 and any(f[1] == "RP06" for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "C", "pages": 70, "deadline": "2026-11-08", "chapters": [1, 12, 23, 31, 42, 55]}]))
ends = [round(it["to"]) for x in r["days"] for it in x["items"]]
t("chapter snapping stops at chapter ends", 11 in ends and 22 in ends and ends[-1] == 70)
t("one item per book per day", all(len({it["title"] for it in x["items"]}) == len(x["items"]) for x in r["days"]))
t("page ranges do not overlap", all(fmt_item(it) for x in r["days"] for it in x["items"]) and
[int(round(it["from"])) for x in r["days"] for it in x["items"]][1:] == [int(round(it["to"])) for x in r["days"] for it in x["items"]][:-1])
r, m = run(dict(base, max_session_minutes=20, minutes=60, books=[{"title": "A", "pages": 300, "deadline": "2026-11-08"}]))
t("long sessions -> RP03", any(f[1] == "RP03" for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "A", "pages": 200, "deadline": "2026-11-08"}]))
t("tight plan -> RP04 or RP01", any(f[1] in ("RP04", "RP01") for f in r["findings"]))
r, m = run(dict(base, books=[{"title": "A", "pages": 30}, {"title": "B", "pages": 60, "deadline": "2026-11-05"}]))
t("undated book after dated ones", r["books"][-1]["title"] == "A" and r["books"][-1]["finish"])
r, m = run(dict(base, books=[{"title": "A", "pages": 20, "start_page": 21}]))
t("finished book -> RP05", any(f[1] == "RP05" for f in r["findings"]))
t("pages_per_hour", abs(load(dict(base, reader={"pages_per_hour": 30}, books=[{"title": "P", "pages": 30}]))["books"][0]["unit_min"] - 2) < 1e-9)
for bad in [dict(base, books=[]), dict(base, books=[{"title": "x"}]), dict(base, minutes={"funday": 3}, books=[{"title": "a", "pages": 3}]),
dict(base, books=[{"title": "a", "pages": 3, "deadline": "2026-13-01"}])]:
try:
run(bad); okk = False
except InputError:
okk = True
t("bad input rejected: %s" % (list(bad.get("minutes", {})) if isinstance(bad.get("minutes"), dict) else (bad["books"][0] if bad["books"] else "no books")), okk)
r, m = run(dict(base, books=[{"title": "Old", "pages": 30, "deadline": "2026-10-01"}]))
t("passed deadline -> RP05, planned without deadline", any(f[1] == "RP05" for f in r["findings"]) and r["books"][0]["finish"])
rep = report(*run(dict(base, books=two)), "even")
t("report has verdict, day table and catch-up advice", "Verdict" in rep and "| Date |" in rep and "If you fall behind" in rep)
for name, okk in tests:
print(("PASS " if okk else "FAIL ") + name)
n = sum(1 for _, okk in tests if okk)
print("%d/%d passed" % (n, len(tests)))
return 0 if n == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("plan", nargs="?")
ap.add_argument("--mode", choices=["even", "asap"], default="even")
ap.add_argument("--from", dest="start", help="re-plan from this date (YYYY-MM-DD)")
ap.add_argument("--at", action="append", default=[], help='"Title=page" (or "Title=minute" for audiobooks): where you are now')
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.plan:
ap.error("plan JSON required (or --self-test)")
try:
try:
cfg = json.load(open(a.plan, encoding="utf-8"))
except (OSError, ValueError) as e:
raise InputError(str(e))
at = {}
for s in a.at:
if "=" not in s:
raise InputError("--at needs Title=page, got %r" % s)
k, v = s.rsplit("=", 1)
try:
at[k.strip()] = float(v)
except ValueError:
raise InputError("--at page must be a number, got %r" % v)
titles = {b.get("title") for b in cfg.get("books", [])} if isinstance(cfg, dict) else set()
for k in at:
if k not in titles:
raise InputError("--at: no book titled %r" % k)
m = load(cfg, a.start, at)
r = plan(m, a.mode)
except (InputError, KeyError, TypeError, ValueError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
out = {"books": [{"title": b["title"], "deadline": str(b["deadline"]) if b["deadline"] else None, "finish": str(b.get("finish")) if b.get("finish") else None,
"minutes_needed": round(b["need"])} for b in r["all_books"]],
"days": [{"date": str(x["date"]), "minutes": round(x["planned"]), "read": [fmt_item(it) for it in x["items"]]} for x in r["days"]],
"findings": [{"severity": s, "code": c, "message": msg} for s, c, msg in r["findings"]]}
print(json.dumps(out, indent=2))
else:
print(report(r, m, a.mode))
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Checks invoices before you pay or send them - recomputes every line (quantity x unit price minus discount) and the VAT or sales tax per line, the tax breakdown and the totals from a JSON or CSV export. Reports arithmetic errors, rates outside your list, per-line vs per-invoice rounding, discounts, duplicate lines and invoices, missing required fields and date problems. Includes an offline stdlib Python checker. Not tax advice.
---
name: invoice-line-item-and-vat-total-checker
description: Checks invoices before you pay or send them - recomputes every line (quantity x unit price minus discount) and the VAT or sales tax per line, the tax breakdown and the totals from a JSON or CSV export. Reports arithmetic errors, rates outside your list, per-line vs per-invoice rounding, discounts, duplicate lines and invoices, missing required fields and date problems. Includes an offline stdlib Python checker. Not tax advice.
---
# Invoice Line Item and VAT Total Checker
You help small businesses, freelancers and finance assistants catch invoice mistakes before money moves: a line total that does not equal quantity x price, a tax amount worked out on the wrong base, a rate that is not one you use, a delivery line taxed at a rate from another country, the same invoice entered twice, a credit note with no VAT ID. You recompute everything from the lines, compare with what the invoice says, and hand back a discrepancy report someone can act on.
You check arithmetic and consistency against the rules the user gives you. You do not decide which tax rate legally applies, whether tax is due or which fields the law requires; say so in every report and point to an accountant or the tax authority for those questions.
## Files in this skill
- `scripts/invoice_check.py` - loads an invoices JSON (header, lines, stated totals, optional tax breakdown) or a flat line-items CSV (one row per line, grouped by invoice number and seller); recomputes line net, tax and gross with Decimal arithmetic, per-line or per-invoice tax rounding, half-up or half-even, tax-exclusive or tax-inclusive prices, line and invoice discounts; reports findings IV01-IV13 and a per-invoice line table (Python 3.8+ standard library only)
- `references/checks-and-codes.md` - every finding code, its severity and what to do about it
- `references/tax-math-and-rounding.md` - the formulas, rounding methods, discounts, credit notes and tolerance
- `templates/discrepancy-report.md` - the report to send to the supplier, client or bookkeeper
- `examples/invoices.json` - five purchase invoices of a design studio: one clean, one with a wrong line, a non-listed rate, a duplicated line and a category rate mismatch, a cloud bill with fractional quantities and a due date before the invoice date, a duplicate entry and a credit note without a VAT ID
- `examples/vat-rules.json` - example rules: allowed rates 20/10/0, a category -> rate list, per-line rounding, required fields (replace the rates with yours)
- `examples/invoice-lines.csv` - two of the studio's own sales invoices as a line-items export, one with a tax error, a line left out of the totals, a missing buyer and a currency that changes mid-invoice
- `examples/invoice-report.md` - the real checker output on invoices.json
## Workflow
### 1. Collect the invoices and the rules
Ask for the invoices as an export (accounting tool CSV, e-invoice JSON) or paste the lines; one file can hold a whole batch. Then ask for the rules: the tax rates the business actually uses, any item categories with their own rate (from their accountant, not from you), whether prices are tax-exclusive or tax-inclusive, how the tool rounds tax (per line or per invoice), the currency decimals and which header fields their invoices must carry. Write them into a rules JSON like `examples/vat-rules.json`.
### 2. Run the checker
```
python3 scripts/invoice_check.py examples/invoices.json --rules examples/vat-rules.json --today 2026-11-03
python3 scripts/invoice_check.py examples/invoice-lines.csv --rules examples/vat-rules.json
python3 scripts/invoice_check.py batch.json --rules rules.json --json
python3 scripts/invoice_check.py --self-test
```
Exit 0 = no high findings, 1 = high findings, 2 = bad input. Without a shell, recompute by hand with `references/tax-math-and-rounding.md`: each line net = round(qty x unit price x (1 - discount)), tax = round(net x rate), then sum per rate and compare with the stated totals.
### 3. Read the result
- High first: IV02/IV03 (a line is wrong), IV05 (totals do not add up), IV04 (a rate not in your list), IV07 (same invoice number twice), IV01 (no number, date, quantity or price). Do not pay or send these.
- Medium: category rate mismatches, duplicate lines, negative lines on a normal invoice, due dates before the invoice date, missing VAT IDs or buyer names, a header that changes between CSV rows.
- Low/info: rounding-method differences (IV06) and extra decimals (IV12) - usually explainable, note them.
- A wrong total often comes from one wrong line: fix the line, then re-run before reporting the total.
### 4. Decide and write it up
For each invoice: OK to pay/send, ask for a corrected invoice or credit note, or check internally (duplicate entry, wrong rule). Fill `templates/discrepancy-report.md` with the line, the stated and recomputed amounts and the exact correction you need. Keep the tone factual: "line 1: 250 x 0.38 = 95.00, invoiced 98.00".
### 5. Deliver
Return the per-invoice verdicts, the discrepancy report(s), a list of questions for the accountant (rates, required fields) and any rule changes for next time (e.g. switch to per-invoice rounding if that is what the supplier's system does).
## Example result
On `examples/invoices.json` 2 of 5 invoices must not be paid as they are. Brightline Print 2026/0877 bills 250 business cards at 0.38 as 98.00 instead of 95.00 (IV02), taxes delivery at 18%, which is not in the studio's rates (IV04), repeats the flyer line (IV07) and taxes catering at 20% where the studio's list says 10% (IV04, medium); its totals are 3.00 net / 0.60 tax too high (IV05). Northwind's INV-2026-1041 was entered twice (IV07, high). CloudNest's tax of 7.38 is per-invoice rounding instead of 7.39 per line (IV06 info, within tolerance) but its due date is before the invoice date; the Brightline credit note has no seller VAT ID. The clean Northwind invoice passes.
## Rules
- Recompute from the lines; never trust a stated total, and report the first wrong line before the totals it breaks.
- Use only the rates and categories the user gives you. If asked which rate applies, say that is a tax question for their accountant or tax authority.
- Use exact decimal arithmetic and the user's rounding method; a 0.01 rounding difference is not an error unless their rules say so.
- Treat duplicates as "possible": show both invoices and let the user confirm.
- Never change an invoice yourself; ask the issuer for a corrected invoice or a credit note.
- Keep invoice data private: do not paste it into public tools or share it beyond the people who need it.
FILE:references/checks-and-codes.md
# Checks and finding codes
| Code | Severity | Check | Typical fix |
|---|---|---|---|
| IV01 | high: number, date, qty, price, no lines, no totals; medium: other fields | required invoice and line fields present and parseable (fields come from `required_invoice_fields` / `required_line_fields`) | ask for a corrected invoice with the missing field |
| IV02 | high | line net = round(qty x unit price x (1 - discount%)); line gross = net + tax | corrected invoice for that line |
| IV03 | high | line tax = round(line net x rate); a tax that is consistent with an already wrong net is not reported twice | corrected invoice |
| IV04 | high: rate not in `allowed_vat_rates`; medium: rate differs from your `category_rates`; low: rate written as a fraction (0.2) | rate sanity against YOUR list | check the rate with your accountant, then ask the issuer |
| IV05 | high (medium if within tolerance x lines) | stated net, tax and grand total vs the recomputed sums; grand total = net + tax | corrected invoice; often follows from IV02/IV03 |
| IV06 | low (info if within tolerance) | tax total matches the other rounding method (per invoice vs per line) | accept, or set `vat_rounding` to what the issuer uses |
| IV07 | high: same seller + invoice number twice; medium: same seller, date and total under another number, or a repeated line | duplicates | confirm with the issuer before paying twice |
| IV08 | high: discount outside 0-100%; info: invoice-level discount applied | discounts reduce the taxable base before tax | corrected invoice |
| IV09 | medium: negative qty/price on a normal invoice; low: zero qty/price | returns belong on a credit note (`"type": "credit_note"`) | ask for a credit note |
| IV10 | high: invalid invoice date; medium: invalid or earlier due date, invoice date in the future | dates | corrected invoice / check the entry |
| IV11 | medium | stated tax breakdown per rate vs the lines | corrected invoice |
| IV12 | low | stated amounts with more decimals than `currency_decimals` (unit prices may have more) | usually cosmetic |
| IV13 | medium | CSV export: header values (currency, buyer, date...) differ between rows of one invoice | fix the export or the source record |
## Verdicts
- **FIX**: at least one high finding. Do not pay or send.
- **CHECK**: medium findings only. Usually a question for the issuer or an internal check.
- **OK**: nothing, or only low/info.
## Input fields
JSON invoice: `number, date, due_date, type (invoice|credit_note), seller{name, vat_id}, buyer{name, vat_id}, currency, invoice_discount_pct, lines[], totals{net, vat, gross}, vat_breakdown[{rate, net, vat}]`.
JSON line: `sku, description, category, qty, unit_price, discount_pct, vat_rate, net, vat, gross` (stated amounts are optional - lines without them are only recomputed).
CSV columns: `invoice_number, invoice_date, due_date, type, seller_name, seller_vat_id, buyer_name, buyer_vat_id, currency, invoice_discount_pct, sku, description, category, qty, unit_price, discount_pct, vat_rate, line_net, vat_amount, line_gross, total_net, total_vat, total_gross` (totals on any row of the invoice). Decimal commas (12,50 and 1.234,56) are accepted.
FILE:references/tax-math-and-rounding.md
# Tax math and rounding
This is arithmetic, not tax law. Rates, exemptions, reverse charge and required invoice fields depend on the country and the transaction: take them from the user's accountant or tax authority.
## Tax-exclusive prices (default)
```
line net = round(qty x unit_price x (1 - discount_pct / 100))
line tax = round(line net x rate / 100) per-line rounding
line gross = line net + line tax
net total = sum(line net) (x (1 - invoice_discount_pct / 100) per rate, rounded)
tax total = sum(line tax) per-line rounding
= sum over rates of round(net of that rate x rate / 100) per-invoice rounding
grand total = net total + tax total
```
## Tax-inclusive prices (`prices_include_vat: true`)
```
line gross = round(qty x unit_price x (1 - discount_pct / 100))
line tax = round(line gross x rate / (100 + rate))
line net = line gross - line tax
```
Example: gross 12.00 at 20% -> tax 2.00, net 10.00.
## Rounding
- `half_up` (default): 0.005 -> 0.01. Most invoicing tools and many tax authorities use it.
- `half_even` (banker's rounding): 0.005 -> 0.00, 0.015 -> 0.02. Some ERP and spreadsheet functions use it.
- Per-line vs per-invoice rounding can differ by up to 0.005 x number of lines. Ten lines of 0.05 at 10%: per line 10 x 0.01 = 0.10, per invoice round(0.50 x 10%) = 0.05. Both can be legitimate; check which method the issuer uses and set `vat_rounding`.
- `tolerance` (default 0.01) is the difference accepted per amount; differences at or below it are not errors (IV06 info shows when they come from the rounding method).
## Discounts
- Line discounts reduce the line net before tax.
- An invoice-level discount (`invoice_discount_pct`) reduces the taxable base of every rate proportionally before tax; the checker applies it per rate.
- Early-payment (settlement) discounts that are only taken later are not modelled; check them separately.
## Credit notes and negative lines
- A credit note (`"type": "credit_note"`) has negative quantities or prices and negative totals; tax is negative too.
- A negative line on a normal invoice (a return netted off) is flagged (IV09) because many systems and auditors expect a separate credit note.
## Multi-currency
- All amounts of one invoice must be in one currency; a currency that changes between CSV rows is flagged (IV13).
- Conversion to your book currency is out of scope: convert after the check with the rate your bookkeeping uses.
FILE:templates/discrepancy-report.md
# Invoice discrepancy report
**To:** <supplier / client / bookkeeper> **From:** <name> **Date:** <YYYY-MM-DD>
**Invoice:** <number> dated <date>, total <stated total> <currency>
We checked this invoice line by line before <paying / sending> it and found the following. Please send a corrected invoice or a credit note for the difference.
| # | Line / field | Invoiced | Should be | Difference | Note |
|---|---|---|---|---|---|
| 1 | <line 1: 250 x 0.38> | <98.00> | <95.00> | <3.00> | <quantity x unit price> |
| 2 | <tax rate on delivery> | <18%> | <rate we expect, confirmed by our accountant> | <...> | <...> |
**Totals**
| | Invoiced | Recomputed |
|---|---|---|
| Net | <...> | <...> |
| Tax | <...> | <...> |
| Total | <...> | <...> |
**Other points**
- <duplicate line / missing VAT ID / due date before invoice date>
**What we need:** <corrected invoice | credit note for X | confirmation that invoice Y is a duplicate>
*Recomputed from the invoice lines with <rounding> rounding, tolerance <0.01>. This is an arithmetic check, not tax advice.*
FILE:examples/invoices.json
{
"invoices": [
{
"number": "INV-2026-1041",
"date": "2026-10-05",
"due_date": "2026-11-04",
"seller": {"name": "Northwind Office Supplies", "vat_id": "GB123456789"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"sku": "PAP-A4-5", "description": "A4 copy paper, box of 5 reams", "qty": 4, "unit_price": "21.50", "vat_rate": 20, "net": "86.00", "vat": "17.20", "gross": "103.20"},
{"sku": "TON-HP-26X", "description": "Toner cartridge 26X", "qty": 2, "unit_price": "89.99", "discount_pct": 5, "vat_rate": 20, "net": "170.98", "vat": "34.20", "gross": "205.18"},
{"sku": "BK-TYPO", "description": "Book: Thinking with Type", "category": "books", "qty": 1, "unit_price": "24.00", "vat_rate": 0, "net": "24.00", "vat": "0.00", "gross": "24.00"}
],
"totals": {"net": "280.98", "vat": "51.40", "gross": "332.38"},
"vat_breakdown": [{"rate": 20, "net": "256.98", "vat": "51.40"}, {"rate": 0, "net": "24.00", "vat": "0.00"}]
},
{
"number": "2026/0877",
"date": "2026-10-12",
"due_date": "2026-10-26",
"seller": {"name": "Brightline Print", "vat_id": "GB987654321"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"sku": "BC-350", "description": "Business cards, 350gsm", "qty": 250, "unit_price": "0.38", "vat_rate": 20, "net": "98.00", "vat": "19.60", "gross": "117.60"},
{"sku": "FLY-A5", "description": "A5 flyers, double-sided", "qty": 1000, "unit_price": "0.12", "discount_pct": 10, "vat_rate": 20, "net": "108.00", "vat": "21.60", "gross": "129.60"},
{"sku": "FLY-A5", "description": "A5 flyers, double-sided", "qty": 1000, "unit_price": "0.12", "discount_pct": 10, "vat_rate": 20, "net": "108.00", "vat": "21.60", "gross": "129.60"},
{"sku": "DEL-STD", "description": "Delivery", "qty": 1, "unit_price": "12.50", "vat_rate": 18, "net": "12.50", "vat": "2.25", "gross": "14.75"},
{"sku": "CAT-01", "description": "Catering for launch event", "category": "catering", "qty": 1, "unit_price": "150.00", "vat_rate": 20, "net": "150.00", "vat": "30.00", "gross": "180.00"}
],
"totals": {"net": "476.50", "vat": "95.05", "gross": "571.55"}
},
{
"number": "CN-8841",
"date": "2026-10-31",
"due_date": "2026-10-01",
"seller": {"name": "CloudNest Hosting", "vat_id": "IE6388047V"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"description": "Object storage, GB-month", "qty": "412.5", "unit_price": "0.0185", "vat_rate": 20},
{"description": "Egress, GB", "qty": "96.3", "unit_price": "0.0725", "vat_rate": 20},
{"description": "Load balancer hours", "qty": 744, "unit_price": "0.0225", "vat_rate": 20},
{"description": "Snapshot storage, GB-month", "qty": "38.4", "unit_price": "0.0420", "vat_rate": 20},
{"description": "DNS queries, per million", "qty": "1.7", "unit_price": "0.35", "vat_rate": 20},
{"description": "Static IP hours", "qty": 744, "unit_price": "0.0045", "vat_rate": 20}
],
"totals": {"net": "36.91", "vat": "7.38", "gross": "44.29"}
},
{
"number": "INV-2026-1041",
"date": "2026-10-05",
"seller": {"name": "Northwind Office Supplies", "vat_id": "GB123456789"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"sku": "PAP-A4-5", "description": "A4 copy paper, box of 5 reams", "qty": 4, "unit_price": "21.50", "vat_rate": 20},
{"sku": "TON-HP-26X", "description": "Toner cartridge 26X", "qty": 2, "unit_price": "89.99", "discount_pct": 5, "vat_rate": 20},
{"sku": "BK-TYPO", "description": "Book: Thinking with Type", "category": "books", "qty": 1, "unit_price": "24.00", "vat_rate": 0}
],
"totals": {"net": "280.98", "vat": "51.40", "gross": "332.38"}
},
{
"number": "CR-0012",
"type": "credit_note",
"date": "2026-10-20",
"seller": {"name": "Brightline Print"},
"buyer": {"name": "Lumen Studio Ltd"},
"currency": "GBP",
"lines": [
{"sku": "BC-350", "description": "Credit: misprinted business cards", "qty": -50, "unit_price": "0.38", "vat_rate": 20, "net": "-19.00", "vat": "-3.80", "gross": "-22.80"}
],
"totals": {"net": "-19.00", "vat": "-3.80", "gross": "-22.80"}
}
]
}
FILE:examples/vat-rules.json
{
"name": "Example rules for a studio's purchase invoices - replace the rates with the ones that apply to you",
"currency_decimals": 2,
"rounding": "half_up",
"vat_rounding": "line",
"tolerance": "0.01",
"prices_include_vat": false,
"allowed_vat_rates": [20, 10, 0],
"category_rates": {"books": 0, "catering": 10, "food": 10},
"required_invoice_fields": ["number", "date", "seller.name", "seller.vat_id", "buyer.name", "currency"],
"required_line_fields": ["description", "qty", "unit_price", "vat_rate"],
"max_discount_pct": 100
}
FILE:examples/invoice-lines.csv
invoice_number,invoice_date,due_date,seller_name,seller_vat_id,buyer_name,currency,sku,description,category,qty,unit_price,discount_pct,vat_rate,line_net,vat_amount,line_gross,total_net,total_vat,total_gross
S-1001,2026-10-28,2026-11-27,Lumen Studio Ltd,GB555000111,Harbour Cafe,GBP,LOGO,"Logo design, 3 concepts",,1,850.00,,20,850.00,170.00,1020.00,1170.00,234.00,1404.00
S-1001,2026-10-28,2026-11-27,Lumen Studio Ltd,GB555000111,Harbour Cafe,GBP,MENU,Menu layout (A4 folded),,2,160.00,,20,320.00,64.00,384.00,,,
S-1002,2026-10-30,2026-11-29,Lumen Studio Ltd,GB555000111,,GBP,WEB-DAY,Website build - day rate,,4.5,480.00,10,20,1944.00,388.80,2332.80,2104.00,420.80,2524.80
S-1002,2026-10-30,2026-11-29,Lumen Studio Ltd,GB555000111,,GBP,PHOTO,"Stock photo licence, 5 images",,5,32.00,,20,160.00,23.00,183.00,,,
S-1002,2026-10-30,2026-11-29,Lumen Studio Ltd,GB555000111,,EUR,BOOK,Brand guidelines book (printed),books,1,45.00,,0,45.00,0.00,45.00,,,
FILE:examples/invoice-report.md
# Invoice check: examples/invoices.json
5 invoice(s), 18 line(s). Rules: half up rounding, tax rounded per line, tolerance 0.01, prices exclude tax, allowed rates 20/10/0%.
**Verdict: 2 of 5 invoice(s) have high findings - do not pay/send until fixed**
Findings: 5 high, 4 medium, 0 low, 1 info.
This is an arithmetic and consistency check against your own rules, not tax advice: confirm rates and required invoice fields with your accountant or tax authority.
## Invoices
| Invoice | Seller | Lines | Stated total | Recomputed | Difference | High | Medium | Low | Status |
|---|---|---|---|---|---|---|---|---|---|
| INV-2026-1041 | Northwind Office Supplies | 3 | 332.38 GBP | 332.38 | 0.00 | 0 | 0 | 0 | OK |
| 2026/0877 | Brightline Print | 5 | 571.55 GBP | 567.95 | 3.60 | 4 | 2 | 0 | FIX |
| CN-8841 | CloudNest Hosting | 6 | 44.29 GBP | 44.30 | -0.01 | 0 | 1 | 0 | CHECK |
| INV-2026-1041 (#4) | Northwind Office Supplies | 3 | 332.38 GBP | 332.38 | 0.00 | 1 | 0 | 0 | FIX |
| CR-0012 (credit note) | Brightline Print | 1 | -22.80 GBP | -22.80 | 0.00 | 0 | 1 | 0 | CHECK |
## 2026/0877 - Brightline Print
| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |
|---|---|---|---|---|---|---|---|---|
| 1 | Business cards, 350gsm | 250 | 0.38 | | 20 | 98.00 / 95.00 | 19.60 / 19.00 | net |
| 2 | A5 flyers, double-sided | 1000 | 0.12 | 10 | 20 | 108.00 / 108.00 | 21.60 / 21.60 | ok |
| 3 | A5 flyers, double-sided | 1000 | 0.12 | 10 | 20 | 108.00 / 108.00 | 21.60 / 21.60 | duplicate |
| 4 | Delivery | 1 | 12.50 | | 18 | 12.50 / 12.50 | 2.25 / 2.25 | rate |
| 5 | Catering for launch event | 1 | 150.00 | | 20 | 150.00 / 150.00 | 30.00 / 30.00 | category rate |
| Totals | Stated | Recomputed |
|---|---|---|
| Net | 476.50 | 473.50 |
| Tax | 95.05 | 94.45 |
| Total | 571.55 | 567.95 |
| Tax by rate | | 20%: 92.20 on 461.00; 18%: 2.25 on 12.50 |
- **HIGH IV02** line 1 (Business cards, 350gsm): line net 98.00 but 250 x 0.38 = 95.00 (diff 3.00)
- **HIGH IV04** line 4 (Delivery): tax rate 18% is not in your allowed rates (0, 10, 20)
- **HIGH IV05** stated net total 476.50 but the lines add up to 473.50 (diff 3.00)
- **HIGH IV05** stated tax total 95.05 but the lines add up to 94.45 (diff 0.60)
- **MEDIUM IV04** line 5 (Catering for launch event): category 'catering' is 10% in your rate list but the line uses 20%
- **MEDIUM IV07** line 3 (A5 flyers, double-sided) repeats line 2 (same item, quantity and price) - duplicate?
## CN-8841 - CloudNest Hosting
| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |
|---|---|---|---|---|---|---|---|---|
| 1 | Object storage, GB-month | 412.5 | 0.0185 | | 20 | - / 7.63 | - / 1.53 | ok |
| 2 | Egress, GB | 96.3 | 0.0725 | | 20 | - / 6.98 | - / 1.40 | ok |
| 3 | Load balancer hours | 744 | 0.0225 | | 20 | - / 16.74 | - / 3.35 | ok |
| 4 | Snapshot storage, GB-month | 38.4 | 0.0420 | | 20 | - / 1.61 | - / 0.32 | ok |
| 5 | DNS queries, per million | 1.7 | 0.35 | | 20 | - / 0.60 | - / 0.12 | ok |
| 6 | Static IP hours | 744 | 0.0045 | | 20 | - / 3.35 | - / 0.67 | ok |
| Totals | Stated | Recomputed |
|---|---|---|
| Net | 36.91 | 36.91 |
| Tax | 7.38 | 7.39 |
| Total | 44.29 | 44.30 |
- **MEDIUM IV10** due date 2026-10-01 is before the invoice date 2026-10-31
- **INFO IV06** tax total 7.38 is per-invoice rounding (line rounding gives 7.39) - within tolerance
## INV-2026-1041 (#4) - Northwind Office Supplies
| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |
|---|---|---|---|---|---|---|---|---|
| 1 | A4 copy paper, box of 5 reams | 4 | 21.50 | | 20 | - / 86.00 | - / 17.20 | ok |
| 2 | Toner cartridge 26X | 2 | 89.99 | 5 | 20 | - / 170.98 | - / 34.20 | ok |
| 3 | Book: Thinking with Type | 1 | 24.00 | | 0 | - / 24.00 | - / 0.00 | ok |
| Totals | Stated | Recomputed |
|---|---|---|
| Net | 280.98 | 280.98 |
| Tax | 51.40 | 51.40 |
| Total | 332.38 | 332.38 |
| Tax by rate | | 20%: 51.40 on 256.98; 0%: 0.00 on 24.00 |
- **HIGH IV07** invoice number INV-2026-1041 from this seller already appears (invoice #1) - duplicate invoice?
## CR-0012 - Brightline Print
| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |
|---|---|---|---|---|---|---|---|---|
| 1 | Credit: misprinted business cards | -50 | 0.38 | | 20 | -19.00 / -19.00 | -3.80 / -3.80 | ok |
| Totals | Stated | Recomputed |
|---|---|---|
| Net | -19.00 | -19.00 |
| Tax | -3.80 | -3.80 |
| Total | -22.80 | -22.80 |
- **MEDIUM IV01** missing invoice field 'seller.vat_id'
FILE:scripts/invoice_check.py
#!/usr/bin/env python3
"""Invoice line item and VAT / sales-tax total checker (stdlib only, Python 3.8+).
Recomputes every line (qty x unit price - discount, tax per line) and every invoice total from
an invoices JSON or a flat line-items CSV, compares them with the stated amounts and reports
arithmetic errors, tax rates outside your list, rounding-method differences, discounts,
duplicate lines and invoices, missing required fields and date problems.
It checks numbers against the rules YOU give it; it does not decide which tax rate applies.
Usage:
invoice_check.py INVOICES.json|LINES.csv [--rules RULES.json] [--today YYYY-MM-DD] [--json]
invoice_check.py --self-test
Exit: 0 = no high findings, 1 = high findings (wrong totals, wrong rate, missing number/date,
duplicate invoice), 2 = bad input.
"""
import argparse, csv, datetime, io, json, re, sys
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN, InvalidOperation
DEFAULT_RULES = {
"currency_decimals": 2,
"rounding": "half_up", # half_up | half_even
"vat_rounding": "line", # line = round tax per line then sum; invoice = per rate on the total
"tolerance": "0.01", # accepted difference per amount
"prices_include_vat": False, # unit prices are gross (tax-inclusive)
"allowed_vat_rates": None, # e.g. [20, 10, 1, 0]; None = do not check
"category_rates": {}, # your own mapping, e.g. {"books": 0}
"required_invoice_fields": ["number", "date", "seller.name", "seller.vat_id", "buyer.name", "currency"],
"required_line_fields": ["description", "qty", "unit_price", "vat_rate"],
"max_discount_pct": 100,
}
SEV = {"high": 0, "medium": 1, "low": 2, "info": 3}
CSV_MAP = {"invoice_number": "number", "invoice_date": "date", "due_date": "due_date", "type": "type",
"seller_name": "seller.name", "seller_vat_id": "seller.vat_id", "buyer_name": "buyer.name",
"buyer_vat_id": "buyer.vat_id", "currency": "currency", "invoice_discount_pct": "invoice_discount_pct",
"total_net": "totals.net", "total_vat": "totals.vat", "total_gross": "totals.gross"}
LINE_MAP = {"sku": "sku", "description": "description", "category": "category", "qty": "qty",
"unit_price": "unit_price", "discount_pct": "discount_pct", "line_net": "net", "vat_rate": "vat_rate",
"vat_amount": "vat", "line_gross": "gross"}
class InputError(Exception):
pass
def D(v, what="number"):
if v is None or (isinstance(v, str) and not v.strip()):
return None
if isinstance(v, float):
v = repr(v)
try:
s = str(v).strip().replace(" ", "")
if re.match(r"^-?\d{1,3}(\.\d{3})+,\d+$", s): # 1.234,56
s = s.replace(".", "").replace(",", ".")
elif re.match(r"^-?\d+,\d+$", s): # 12,50
s = s.replace(",", ".")
else:
s = s.replace(",", "") # 1,234.56
s = s.rstrip("%")
return Decimal(s)
except (InvalidOperation, ValueError):
raise InputError("not a %s: %r" % (what, v))
def get(d, path):
for k in path.split("."):
if not isinstance(d, dict):
return None
d = d.get(k)
return d
def setp(d, path, v):
ks = path.split(".")
for k in ks[:-1]:
d = d.setdefault(k, {})
d[ks[-1]] = v
def blank(v):
return v is None or (isinstance(v, str) and not v.strip())
# ---------------------------------------------------------------- loading
def load_invoices(path_or_text, is_csv=None):
text = path_or_text
if is_csv is None:
is_csv = not text.lstrip().startswith(("{", "["))
if not is_csv:
try:
data = json.loads(text)
except ValueError as e:
raise InputError("invalid JSON: %s" % e)
invs = data.get("invoices") if isinstance(data, dict) else data
if not isinstance(invs, list):
raise InputError("JSON must be a list of invoices or {\"invoices\": [...]}")
rules = data.get("rules") if isinstance(data, dict) else None
return invs, rules, []
rows = list(csv.DictReader(io.StringIO(text.lstrip("\ufeff"))))
if not rows:
raise InputError("CSV has no rows")
cols = set(rows[0].keys())
if "invoice_number" not in cols or not ({"qty", "unit_price"} <= cols):
raise InputError("CSV needs at least invoice_number, qty and unit_price columns")
invs, order, notes = {}, [], []
for n, row in enumerate(rows, 2):
key = ((row.get("seller_vat_id") or row.get("seller_name") or "").strip(), (row.get("invoice_number") or "").strip())
if key not in invs:
inv = {"_rows": [n]}
for c, p in CSV_MAP.items():
if c in row and not blank(row[c]):
setp(inv, p, row[c].strip())
invs[key] = inv; order.append(key)
else:
inv = invs[key]; inv["_rows"].append(n)
for c, p in CSV_MAP.items():
if c in row and not blank(row[c]) and not blank(get(inv, p)) and str(get(inv, p)) != row[c].strip():
notes.append((inv, "row %d: %s is '%s' but earlier rows say '%s'" % (n, c, row[c].strip(), get(inv, p))))
elif c in row and not blank(row[c]) and blank(get(inv, p)):
setp(inv, p, row[c].strip())
line = {"_row": n}
for c, p in LINE_MAP.items():
if c in row and not blank(row[c]):
line[p] = row[c].strip()
inv.setdefault("lines", []).append(line)
return [invs[k] for k in order], None, notes
def parse_date(s):
try:
return datetime.date.fromisoformat(str(s)[:10]) if not blank(s) else None
except ValueError:
return "bad"
# ---------------------------------------------------------------- checking
def check(invoices, rules, today=None, csv_notes=()):
R = dict(DEFAULT_RULES); R.update(rules or {})
q = Decimal(1).scaleb(-int(R["currency_decimals"]))
mode = ROUND_HALF_EVEN if R["rounding"] == "half_even" else ROUND_HALF_UP
tol = Decimal(str(R["tolerance"]))
rnd = lambda x: x.quantize(q, rounding=mode)
allowed = None if R["allowed_vat_rates"] is None else {Decimal(str(r)) for r in R["allowed_vat_rates"]}
catmap = {k.lower(): Decimal(str(v)) for k, v in (R.get("category_rates") or {}).items()}
results, seen_numbers, seen_amounts = [], {}, {}
notes_by = {}
for inv, msg in csv_notes:
notes_by.setdefault(id(inv), []).append(msg)
for idx, inv in enumerate(invoices):
F = []
def f(sev, code, msg):
F.append((sev, code, msg))
num = inv.get("number")
label = str(num) if not blank(num) else "#%d (no number)" % (idx + 1)
if any(str(o.get("number")) == str(num) for o in invoices[:idx]) and not blank(num):
label += " (#%d)" % (idx + 1)
credit = str(inv.get("type", "")).lower().replace(" ", "_") in ("credit_note", "credit", "refund")
for p in R["required_invoice_fields"]:
if blank(get(inv, p)):
f("high" if p in ("number", "date") else "medium", "IV01", "missing invoice field '%s'" % p)
for m in notes_by.get(id(inv), []):
f("medium", "IV13", "inconsistent header across CSV rows: " + m)
d = parse_date(inv.get("date")); dd = parse_date(inv.get("due_date"))
if d == "bad":
f("high", "IV10", "invoice date '%s' is not YYYY-MM-DD" % inv.get("date")); d = None
if dd == "bad":
f("medium", "IV10", "due date '%s' is not YYYY-MM-DD" % inv.get("due_date")); dd = None
if d and dd and dd < d:
f("medium", "IV10", "due date %s is before the invoice date %s" % (dd, d))
if d and today and d > today:
f("medium", "IV10", "invoice date %s is in the future (today %s)" % (d, today))
lines, per_rate, sigs = [], {}, {}
sum_net = sum_vat_line = Decimal(0)
for ln_i, ln in enumerate(inv.get("lines") or [], 1):
L = {"n": ln_i, "desc": str(ln.get("description") or ln.get("sku") or "")[:40], "issues": []}
where = "line %d (%s)" % (ln_i, L["desc"] or "no description")
for p in R["required_line_fields"]:
if blank(ln.get(p)):
f("high" if p in ("qty", "unit_price") else "medium", "IV01", "%s: missing '%s'" % (where, p)); L["issues"].append("missing " + p)
try:
qty = D(ln.get("qty"), "quantity"); up = D(ln.get("unit_price"), "unit price")
disc = D(ln.get("discount_pct"), "discount") or Decimal(0)
rate = D(ln.get("vat_rate"), "tax rate")
s_net, s_vat, s_gross = D(ln.get("net")), D(ln.get("vat")), D(ln.get("gross"))
except InputError as e:
f("high", "IV01", "%s: %s" % (where, e)); L["issues"].append("unparseable"); lines.append(L); continue
if rate is not None and Decimal(0) < rate < 1:
f("low", "IV04", "%s: tax rate %s looks like a fraction - read as %s%%" % (where, rate, rate * 100)); rate = rate * 100
if qty is None or up is None:
lines.append(L); continue
if disc < 0 or disc > Decimal(str(R["max_discount_pct"])):
f("high", "IV08", "%s: discount %s%% is outside 0-%s%%" % (where, disc, R["max_discount_pct"])); L["issues"].append("discount")
if (qty < 0 or up < 0) and not credit:
f("medium", "IV09", "%s: negative quantity or price on an invoice - should this be a credit note?" % where); L["issues"].append("negative")
if qty == 0 or up == 0:
f("low", "IV09", "%s: zero quantity or price" % where)
r = rate if rate is not None else Decimal(0)
base = qty * up * (1 - disc / 100)
if R["prices_include_vat"]:
gross = rnd(base); vat = rnd(gross * r / (100 + r)); net = gross - vat
else:
net = rnd(base); vat = rnd(net * r / 100); gross = net + vat
L.update(qty=qty, up=up, disc=disc, rate=rate, net=net, vat=vat, gross=gross, s_net=s_net, s_vat=s_vat, s_gross=s_gross)
if rate is not None:
if allowed is not None and rate not in allowed:
f("high", "IV04", "%s: tax rate %s%% is not in your allowed rates (%s)" % (where, plain(rate), ", ".join(plain(x) for x in sorted(allowed))))
L["issues"].append("rate")
cat = str(ln.get("category") or "").lower()
if cat in catmap and catmap[cat] != rate:
f("medium", "IV04", "%s: category '%s' is %s%% in your rate list but the line uses %s%%" % (where, cat, plain(catmap[cat]), plain(rate)))
L["issues"].append("category rate")
if s_net is not None and abs(s_net - net) > tol:
f("high", "IV02", "%s: line net %s but %s x %s%s = %s (diff %s)" % (where, s_net, plain(qty), up, " - %s%%" % plain(disc) if disc else "", net, s_net - net))
L["issues"].append("net")
if s_vat is not None and abs(s_vat - vat) > tol:
exp_on_stated = rnd((s_net if s_net is not None else net) * r / 100)
if s_net is not None and abs(s_vat - exp_on_stated) <= tol:
pass # consistent with its own (wrong) net; already reported as IV02
else:
f("high", "IV03", "%s: tax %s but %s%% of %s = %s (diff %s)" % (where, s_vat, plain(r), net, vat, s_vat - vat))
L["issues"].append("tax")
if s_gross is not None and s_net is not None and s_vat is not None and abs(s_gross - (s_net + s_vat)) > tol:
f("high", "IV02", "%s: line gross %s is not net %s + tax %s" % (where, s_gross, s_net, s_vat)); L["issues"].append("gross")
for v, nm in ((s_net, "net"), (s_vat, "tax"), (s_gross, "gross")):
if v is not None and v != v.quantize(q, rounding=mode):
f("low", "IV12", "%s: stated %s %s has more than %s decimals" % (where, nm, v, R["currency_decimals"]))
sig = (str(ln.get("sku") or "").lower(), L["desc"].lower(), qty, up)
if sig in sigs:
f("medium", "IV07", "%s repeats line %d (same item, quantity and price) - duplicate?" % (where, sigs[sig])); L["issues"].append("duplicate")
else:
sigs[sig] = ln_i
pr = per_rate.setdefault(r, [Decimal(0), Decimal(0)])
pr[0] += net; pr[1] += vat
sum_net += net; sum_vat_line += vat
lines.append(L)
if not lines:
f("high", "IV01", "invoice has no lines")
# invoice-level discount reduces the taxable base proportionally
idisc = D(inv.get("invoice_discount_pct"), "invoice discount") if not blank(inv.get("invoice_discount_pct")) else None
if idisc:
if idisc < 0 or idisc > 100:
f("high", "IV08", "invoice discount %s%% is outside 0-100%%" % idisc)
factor = 1 - idisc / 100
for r in per_rate:
n_r = rnd(per_rate[r][0] * factor)
per_rate[r] = [n_r, rnd(n_r * r / 100)]
sum_net = sum(v[0] for v in per_rate.values())
sum_vat_line = sum(v[1] for v in per_rate.values())
f("info", "IV08", "invoice discount %s%% applied before tax (taxable base reduced on every rate)" % idisc)
vat_invoice_mode = sum((rnd(v[0] * r / 100) for r, v in per_rate.items()), Decimal(0))
exp_vat = sum_vat_line if R["vat_rounding"] == "line" else vat_invoice_mode
alt_vat = vat_invoice_mode if R["vat_rounding"] == "line" else sum_vat_line
exp = {"net": sum_net, "vat": exp_vat, "gross": sum_net + exp_vat}
if R["prices_include_vat"] and not idisc:
exp["gross"] = sum(L.get("gross", 0) for L in lines)
exp["net"] = exp["gross"] - exp_vat
T = inv.get("totals") or {}
try:
st = {k: D(T.get(k), "total") for k in ("net", "vat", "gross")}
except InputError as e:
f("high", "IV05", str(e)); st = {"net": None, "vat": None, "gross": None}
if all(v is None for v in st.values()):
f("high", "IV01", "no stated totals (net / tax / gross) to check")
for k, nm in (("net", "net total"), ("vat", "tax total"), ("gross", "grand total")):
if st[k] is None:
continue
diff = st[k] - exp[k]
if abs(diff) <= tol:
if k == "vat" and diff and st[k] == alt_vat:
f("info", "IV06", "tax total %s is %s rounding (%s rounding gives %s) - within tolerance" % (
st[k], "per-invoice" if R["vat_rounding"] == "line" else "per-line", R["vat_rounding"], exp_vat))
continue
if k == "vat" and abs(st[k] - alt_vat) <= tol:
f("low", "IV06", "tax total %s matches %s rounding (%s) instead of your %s rounding (%s) - a rounding-method difference of %s" % (
st[k], "per-invoice" if R["vat_rounding"] == "line" else "per-line", alt_vat, R["vat_rounding"], exp_vat, diff))
exp["vat"] = alt_vat; exp["gross"] = exp["net"] + alt_vat
continue
if k == "gross" and st["net"] is not None and st["vat"] is not None and abs(st["gross"] - (st["net"] + st["vat"])) <= tol and (abs(st["net"] - exp["net"]) > tol or abs(st["vat"] - exp["vat"]) > tol):
continue # follows from the net/tax error already reported
sev = "high" if abs(diff) > tol * max(1, len(lines)) else "medium"
f(sev, "IV05", "stated %s %s but the lines add up to %s (diff %s)" % (nm, st[k], exp[k], diff))
if st["net"] is not None and st["vat"] is not None and st["gross"] is not None and abs(st["gross"] - st["net"] - st["vat"]) > tol:
f("high", "IV05", "stated grand total %s is not stated net %s + tax %s" % (st["gross"], st["net"], st["vat"]))
for b in inv.get("vat_breakdown") or []:
try:
br, bn, bv = D(b.get("rate")), D(b.get("net")), D(b.get("vat"))
except InputError as e:
f("medium", "IV11", "tax breakdown: %s" % e); continue
got = per_rate.get(br)
if got is None:
f("medium", "IV11", "tax breakdown lists %s%% but no line uses that rate" % br)
elif (bn is not None and abs(bn - got[0]) > tol) or (bv is not None and abs(bv - got[1]) > tol and abs(bv - rnd(got[0] * br / 100)) > tol):
f("medium", "IV11", "tax breakdown %s%%: stated base %s / tax %s, lines give %s / %s" % (br, bn, bv, got[0], got[1]))
seller = str(get(inv, "seller.vat_id") or get(inv, "seller.name") or "").strip().lower()
if not blank(num):
k = (seller, str(num).strip().lower())
if k in seen_numbers:
f("high", "IV07", "invoice number %s from this seller already appears (invoice #%d) - duplicate invoice?" % (num, seen_numbers[k] + 1))
else:
seen_numbers[k] = idx
if st["gross"] is not None and d:
k = (seller, d, st["gross"])
if k in seen_amounts and str(invoices[seen_amounts[k]].get("number")) != str(num):
f("medium", "IV07", "same seller, date and total %s as invoice %s - possible duplicate under a new number" % (st["gross"], invoices[seen_amounts[k]].get("number")))
seen_amounts.setdefault(k, idx)
F.sort(key=lambda x: (SEV[x[0]], x[1]))
results.append({"label": label, "seller": get(inv, "seller.name") or "", "currency": inv.get("currency") or "",
"credit": credit, "lines": lines, "per_rate": per_rate, "expected": exp, "stated": st,
"findings": F, "rows": inv.get("_rows")})
return results, R
# ---------------------------------------------------------------- rendering
def plain(v):
"""Plain decimal without exponent or trailing zeros (250, 0.0185, 12.5)."""
if v is None:
return "-"
t = format(v.normalize(), "f")
return t
def money(v):
return "-" if v is None else "{:,.2f}".format(v)
def report(results, R, src):
tot = {s: sum(1 for r in results for f in r["findings"] if f[0] == s) for s in SEV}
bad = [r for r in results if any(f[0] == "high" for f in r["findings"])]
out = ["# Invoice check: %s" % src, ""]
out.append("%d invoice(s), %d line(s). Rules: %s rounding, tax rounded per %s, tolerance %s, prices %s tax%s." % (
len(results), sum(len(r["lines"]) for r in results), R["rounding"].replace("_", " "), R["vat_rounding"], R["tolerance"],
"include" if R["prices_include_vat"] else "exclude",
", allowed rates " + "/".join(str(x) for x in R["allowed_vat_rates"]) + "%" if R["allowed_vat_rates"] is not None else ""))
out += ["", "**Verdict: %s**" % ("%d of %d invoice(s) have high findings - do not pay/send until fixed" % (len(bad), len(results)) if bad else "no high findings"),
"", "Findings: %d high, %d medium, %d low, %d info." % (tot["high"], tot["medium"], tot["low"], tot["info"]), "",
"This is an arithmetic and consistency check against your own rules, not tax advice: confirm rates and required invoice fields with your accountant or tax authority.", "",
"## Invoices", "", "| Invoice | Seller | Lines | Stated total | Recomputed | Difference | High | Medium | Low | Status |", "|---|---|---|---|---|---|---|---|---|---|"]
for r in results:
c = {s: sum(1 for f in r["findings"] if f[0] == s) for s in SEV}
sg, eg = r["stated"]["gross"], r["expected"]["gross"]
out.append("| %s%s | %s | %d | %s %s | %s | %s | %d | %d | %d | %s |" % (
r["label"], " (credit note)" if r["credit"] else "", r["seller"], len(r["lines"]), money(sg), r["currency"], money(eg),
money(sg - eg) if sg is not None else "-", c["high"], c["medium"], c["low"],
"FIX" if c["high"] else ("CHECK" if c["medium"] else "OK")))
for r in results:
if not r["findings"] or all(f[0] == "info" for f in r["findings"]):
continue
out += ["", "## %s - %s" % (r["label"], r["seller"]), ""]
out += ["| # | Item | Qty | Unit price | Disc % | Rate % | Net (stated / calc) | Tax (stated / calc) | Issues |", "|---|---|---|---|---|---|---|---|---|"]
for L in r["lines"]:
if "net" not in L:
out.append("| %d | %s | - | - | - | - | - | - | %s |" % (L["n"], L["desc"], ", ".join(L["issues"])))
continue
out.append("| %d | %s | %s | %s | %s | %s | %s / %s | %s / %s | %s |" % (
L["n"], L["desc"], plain(L["qty"]), L["up"], plain(L["disc"]) if L["disc"] else "", plain(L["rate"]),
money(L["s_net"]), money(L["net"]), money(L["s_vat"]), money(L["vat"]), ", ".join(L["issues"]) or "ok"))
out += ["", "| Totals | Stated | Recomputed |", "|---|---|---|"]
for k, nm in (("net", "Net"), ("vat", "Tax"), ("gross", "Total")):
out.append("| %s | %s | %s |" % (nm, money(r["stated"][k]), money(r["expected"][k])))
if len(r["per_rate"]) > 1:
out.append("| Tax by rate | | %s |" % "; ".join("%s%%: %s on %s" % (plain(k), money(v[1]), money(v[0])) for k, v in sorted(r["per_rate"].items(), reverse=True)))
out += [""] + ["- **%s %s** %s" % (s.upper(), c, m) for s, c, m in r["findings"]]
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def inv(lines, totals, **kw):
base = {"number": "A1", "date": "2026-10-01", "seller": {"name": "S", "vat_id": "X1"}, "buyer": {"name": "B"},
"currency": "EUR", "lines": lines, "totals": totals}
base.update(kw); return base
codes = lambda res: {f[1] for f in res[0]["findings"]}
sev = lambda res, c: {f[0] for f in res[0]["findings"] if f[1] == c}
ok = [{"description": "a", "qty": 2, "unit_price": "10.00", "vat_rate": 20, "net": "20.00", "vat": "4.00", "gross": "24.00"}]
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"})], {})
t("clean invoice has no findings", not res[0]["findings"])
res, _ = check([inv([dict(ok[0], net="22.00", vat="4.40", gross="26.40")], {"net": "22.00", "vat": "4.40", "gross": "26.40"})], {})
t("wrong line net -> IV02 high", "high" in sev(res, "IV02"))
t("tax consistent with wrong net not double-reported", "IV03" not in codes(res))
res, _ = check([inv([dict(ok[0], vat="3.60", gross="23.60")], {"net": "20.00", "vat": "3.60", "gross": "23.60"})], {})
t("wrong line tax -> IV03", "IV03" in codes(res))
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"})], {"allowed_vat_rates": [10, 0]})
t("rate not allowed -> IV04 high", "high" in sev(res, "IV04"))
res, _ = check([inv([dict(ok[0], category="books")], {"net": "20.00", "vat": "4.00", "gross": "24.00"})], {"category_rates": {"books": 0}})
t("category rate mismatch -> IV04 medium", "medium" in sev(res, "IV04"))
ten = [{"description": "x%d" % i, "qty": 1, "unit_price": "0.05", "vat_rate": 10} for i in range(10)]
res, _ = check([inv(ten, {"net": "0.50", "vat": "0.05", "gross": "0.55"})], {})
t("per-invoice rounding detected -> IV06 low", "IV06" in codes(res) and "IV05" not in codes(res))
res, _ = check([inv(ten, {"net": "0.50", "vat": "0.10", "gross": "0.60"})], {})
res2, _ = check([inv(ten[:2], {"net": "0.10", "vat": "0.01", "gross": "0.11"})], {})
t("rounding difference within tolerance -> IV06 info", sev(res2, "IV06") == {"info"})
t("per-line rounding: 10 x 0.01 = 0.10 ok", not res[0]["findings"])
res, _ = check([inv(ten, {"net": "0.50", "vat": "0.05", "gross": "0.55"})], {"vat_rounding": "invoice"})
t("vat_rounding=invoice accepts 0.05", not res[0]["findings"])
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "25.00"})], {})
t("grand total wrong -> IV05 high", "high" in sev(res, "IV05"))
d = [{"description": "a", "qty": 1, "unit_price": "100", "discount_pct": 15, "vat_rate": 20}]
res, _ = check([inv(d, {"net": "85.00", "vat": "17.00", "gross": "102.00"})], {})
t("line discount applied before tax", not res[0]["findings"])
res, _ = check([inv(d, {"net": "85.00", "vat": "17.00", "gross": "102.00"}, invoice_discount_pct="10")], {})
t("invoice discount reduces base", res[0]["expected"]["gross"] == Decimal("91.80"))
res, _ = check([inv(ok + ok, {"net": "40.00", "vat": "8.00", "gross": "48.00"})], {})
t("duplicate line -> IV07 medium", "medium" in sev(res, "IV07"))
a = inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"})
res, _ = check([a, dict(a)], {})
t("duplicate invoice number -> IV07 high", any(f[1] == "IV07" and f[0] == "high" for f in res[1]["findings"]))
res, _ = check([a, dict(a, number="A2")], {})
t("same seller/date/total new number -> IV07 medium", any(f[1] == "IV07" and f[0] == "medium" for f in res[1]["findings"]))
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"}, number="", seller={"name": "S"})], {})
t("missing number high, missing vat id medium", "high" in sev(res, "IV01") and "medium" in sev(res, "IV01"))
res, _ = check([inv(ok, {"net": "20.00", "vat": "4.00", "gross": "24.00"}, due_date="2026-09-01")], {}, today=datetime.date(2026, 9, 1))
t("due before date + future date -> IV10", len([f for f in res[0]["findings"] if f[1] == "IV10"]) == 2)
neg = [{"description": "return", "qty": -1, "unit_price": "10", "vat_rate": 20}]
res, _ = check([inv(neg, {"net": "-10.00", "vat": "-2.00", "gross": "-12.00"})], {})
t("negative line on invoice -> IV09", "IV09" in codes(res))
res, _ = check([inv(neg, {"net": "-10.00", "vat": "-2.00", "gross": "-12.00"}, type="credit_note")], {})
t("negative ok on credit note", "IV09" not in codes(res))
g = [{"description": "a", "qty": 1, "unit_price": "12.00", "vat_rate": 20}]
res, _ = check([inv(g, {"net": "10.00", "vat": "2.00", "gross": "12.00"})], {"prices_include_vat": True})
t("tax-inclusive prices", not res[0]["findings"])
t("decimal comma parsing", D("1.234,56") == Decimal("1234.56") and D("12,5") == Decimal("12.5") and D("1,234.56") == Decimal("1234.56"))
csvt = ("invoice_number,invoice_date,seller_name,seller_vat_id,buyer_name,currency,description,qty,unit_price,vat_rate,line_net,vat_amount,total_net,total_vat,total_gross\n"
"C1,2026-10-01,S,X1,B,EUR,a,1,10,20,10.00,2.00,15.00,3.00,18.00\nC1,2026-10-01,S,X1,B,USD,b,1,5,20,5.00,1.00,,,\n")
invs, _, notes = load_invoices(csvt)
res, _ = check(invs, {}, csv_notes=notes)
t("CSV grouped into one invoice", len(invs) == 1 and len(invs[0]["lines"]) == 2 and not any(f[0] == "high" for f in res[0]["findings"]))
t("CSV inconsistent header -> IV13", "IV13" in codes(res))
try:
load_invoices("{not json"); okk = False
except InputError:
okk = True
t("bad JSON rejected", okk)
res, _ = check([inv([dict(ok[0], qty="two")], {"gross": "24"})], {})
t("unparseable qty -> IV01 high", "high" in sev(res, "IV01"))
rep = report(*check([a], {}), "x")
t("report has verdict and not-tax-advice line", "Verdict" in rep and "not tax advice" in rep)
for name, okk in tests:
print(("PASS " if okk else "FAIL ") + name)
n = sum(1 for _, okk in tests if okk)
print("%d/%d passed" % (n, len(tests)))
return 0 if n == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("invoices", nargs="?", help="invoices .json or line items .csv")
ap.add_argument("--rules", help="rules JSON (rates, rounding, tolerance, required fields)")
ap.add_argument("--today", help="YYYY-MM-DD for future-date checks (default: today)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.invoices:
ap.error("invoices file required (or --self-test)")
try:
try:
text = open(a.invoices, encoding="utf-8-sig").read()
except OSError as e:
raise InputError(str(e))
invs, emb_rules, notes = load_invoices(text, a.invoices.lower().endswith(".csv") or None)
rules = dict(emb_rules or {})
if a.rules:
try:
rules.update(json.load(open(a.rules, encoding="utf-8")))
except (OSError, ValueError) as e:
raise InputError("rules: %s" % e)
unknown = set(rules) - set(DEFAULT_RULES) - {"name", "note"}
if unknown:
raise InputError("unknown rules key(s): %s" % ", ".join(sorted(unknown)))
if rules.get("vat_rounding", "line") not in ("line", "invoice") or rules.get("rounding", "half_up") not in ("half_up", "half_even"):
raise InputError("vat_rounding must be line|invoice and rounding half_up|half_even")
today = datetime.date.fromisoformat(a.today) if a.today else datetime.date.today()
results, R = check(invs, rules, today, notes)
except (InputError, ValueError) as e:
print("error: %s" % e, file=sys.stderr)
return 2
if a.json:
conv = lambda v: str(v) if isinstance(v, Decimal) else v
out = [{"invoice": r["label"], "seller": r["seller"], "currency": r["currency"],
"stated": {k: conv(v) for k, v in r["stated"].items()}, "recomputed": {k: conv(v) for k, v in r["expected"].items()},
"tax_by_rate": {str(k): [str(v[0]), str(v[1])] for k, v in r["per_rate"].items()},
"findings": [{"severity": s, "code": c, "message": m} for s, c, m in r["findings"]]} for r in results]
print(json.dumps({"invoices": out}, indent=2))
else:
print(report(results, R, a.invoices))
return 1 if any(f[0] == "high" for r in results for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Turns a git log in Conventional Commits format into a ready-to-paste Keep a Changelog release section - Added, Changed, Deprecated, Removed, Fixed, Security - with scopes, issue and commit links and a breaking-changes block. Flags breaking changes without migration notes, non-conforming and fixup commits, reverts and duplicates, and suggests the next SemVer version. Includes an offline stdlib Python builder with --self-test.
---
name: conventional-commits-changelog-builder
description: Turns a git log in Conventional Commits format into a ready-to-paste Keep a Changelog release section - Added, Changed, Deprecated, Removed, Fixed, Security - with scopes, issue and commit links and a breaking-changes block. Flags breaking changes without migration notes, non-conforming and fixup commits, reverts and duplicates, and suggests the next SemVer version. Includes an offline stdlib Python builder with --self-test.
---
# Conventional Commits Changelog Builder
You help maintainers turn the commits since the last tag into a changelog entry people can actually read. Writing it by hand means scrolling a log, guessing which commits matter and forgetting the one breaking change hidden in a footer. Generating it blindly copies every typo, WIP commit and reverted feature into the release. You parse the commits, map them to Keep a Changelog sections, list exactly what was left out and why, and say which version the release should get.
## Files in this skill
- `scripts/changelog_build.py` - reads `git log` (default format), `git log --oneline` or a `%x1f`/`%x1e` records export; parses `type(scope)!: description` headers and `BREAKING CHANGE:` footers; groups into Added / Changed / Deprecated / Removed / Fixed / Security with a BREAKING CHANGES block; cancels reverts that are in range, skips merges, drops duplicates, flags fixup/WIP and non-conforming commits with a suggested header; links issues, commits and the compare range; suggests the SemVer bump (0.x aware); can insert the section into an existing CHANGELOG.md (Python 3.8+ standard library only)
- `references/commit-to-changelog-mapping.md` - the type -> section table, breaking-change rules, bump rules and the finding codes CC01-CC10
- `references/release-hygiene.md` - writing entries for users, migration notes, what to do with non-conforming history, and CI checks
- `templates/release-notes-review.md` - the review to hand back with the section
- `examples/git-log.txt` - 21 commits of a fictional CLI (tidyq) since v2.3.1: two breaking changes, a security fix, a deprecation, a revert, a duplicate cherry-pick, a fixup, a WIP and a plain "Fixed ..." commit
- `examples/CHANGELOG.md` - the project's existing changelog (Unreleased + 2.3.1 + 2.3.0)
- `examples/changelog-report.md` - the real builder output on that log
## Workflow
### 1. Get the commit range
Ask for the last released tag and export everything after it, newest first:
```
git log v2.3.1..HEAD > commits.txt
git log v2.3.1..HEAD --format='%H%x1f%s%x1f%b%x1e' > commits.txt # safest: keeps bodies, no parsing guesswork
```
Also ask for the current version, the release date, the repository URL (for links), the tag prefix (`v` by default) and whether internal commits (refactor, docs, ci, chore, test) should be listed. A squash-merge workflow is ideal: one PR = one commit = one line.
### 2. Run the builder
```
python3 scripts/changelog_build.py examples/git-log.txt --current 2.3.1 --date 2026-11-03 --repo-url https://github.com/tidyq/tidyq
python3 scripts/changelog_build.py commits.txt --current 2.3.1 --insert CHANGELOG.md > CHANGELOG.new.md
python3 scripts/changelog_build.py commits.txt --current 0.9.4 --include-internal --json
python3 scripts/changelog_build.py --self-test
```
Exit 0 = ready to tag, 1 = high findings, 2 = bad input. Without a shell, apply `references/commit-to-changelog-mapping.md` by hand: parse each header, map type to section, collect `!` and `BREAKING CHANGE:` footers, cancel reverts, then pick the highest bump.
### 3. Fix the findings before tagging
- CC03 (high): a `!` commit with no `BREAKING CHANGE:` footer gives users no migration path. Write the note (what breaks, what to do instead) into the section.
- CC08 (high): non-conforming commits that look like features or fixes are missing from the changelog. Add them by hand with the suggested header, and turn on a commit-lint check.
- CC07: fixup/WIP commits should be squashed before the release; CC04: reverts of older releases need a sentence explaining why.
- CC01/CC02/CC05/CC06: wording and conformance; fix in the changelog text, not by rewriting published history.
### 4. Edit for humans
The builder gives you correct structure, not good prose. Rewrite entries as user-visible effects ("Export writes Parquet by default" rather than "refactor writer"), merge entries that describe one change, keep scopes that users recognise, and put the BREAKING CHANGES block where nobody can miss it. See `references/release-hygiene.md`.
### 5. Deliver
Fill `templates/release-notes-review.md`: version and why, the final section, the migration notes, what was left out, and follow-ups (commitlint, PR title check). With `--insert`, hand back the updated CHANGELOG.md; the original file is never modified.
## Example result
On `examples/git-log.txt` (current 2.3.1) the builder reads 21 commits, 85% conforming, and suggests **3.0.0**: `feat(export)!` makes Parquet the default (with a proper BREAKING CHANGE footer) and `fix(config)!` drops the INI config file without any migration note (CC03, high). "Fixed crash when the config file is empty" is not a Conventional Commit and would silently miss the changelog (CC08, high; suggested `fix: crash when the config file is empty`). The token-leak fix lands under Security, the `--legacy-dates` deprecation under Deprecated, the colour-themes feature and its revert cancel out, the cherry-picked BOM fix is listed once, and the merge, fixup, WIP and 5 internal commits are listed as left out.
## Rules
- Never invent entries: every line comes from a commit, and every commit is either in the section or in the "left out" list.
- A breaking change always gets a migration note and the major bump (minor while on 0.x); never hide it in Changed.
- Security fixes go under Security even when the commit type is `fix`.
- Do not rewrite published history to fix commit messages; fix the changelog text and add a lint check for the future.
- Keep the newest release on top, dates as YYYY-MM-DD and one compare link per version.
- The version is a suggestion: the maintainer decides, especially for behaviour changes typed as `fix`.
FILE:references/commit-to-changelog-mapping.md
# Commit -> changelog mapping
## Header grammar (Conventional Commits 1.0.0)
```
<type>[(<scope>)][!]: <description>
[body]
[footers] e.g. BREAKING CHANGE: <what breaks and how to migrate>
Closes #123
Refs: #98
```
- `type` is a noun such as feat or fix; lower case by convention.
- Exactly one space after the colon. `fix:missing space` or `fix : x` is malformed (CC01, with a suggested fix).
- `!` before the colon marks a breaking change. A `BREAKING CHANGE:` (or `BREAKING-CHANGE:`) footer also marks one, with or without `!`.
## Type -> Keep a Changelog section
| Commit | Section | Bump |
|---|---|---|
| feat | Added | minor |
| feat with "deprecate" in the description | Deprecated | minor |
| feat / breaking with remove, drop, delete first | Removed | minor / major |
| fix | Fixed | patch |
| fix / deps mentioning security, CVE, vulnerability, XSS, CSRF, injection, leak; type security | Security | patch |
| perf, deps | Changed | patch |
| any type with `!` or BREAKING CHANGE footer | its section + BREAKING CHANGES block | major (minor on 0.x) |
| refactor, docs, style, test, build, ci, chore | not listed (Internal with --include-internal) | none |
| revert of a commit in the same range | both left out | none |
| revert of an older commit | Removed (reverted feat) or Fixed | patch |
| merge commits | left out | none |
| fixup!, squash!, amend!, WIP | left out + CC07 | none |
Aliases accepted with a CC02 warning: feature/features/add -> feat, bugfix/bug/hotfix/fixes/fixed -> fix, doc/documentation -> docs, tests -> test, performance -> perf, refactoring -> refactor, cleanup/release -> chore, dep -> deps. Other unknown types are left out.
## Bump rules
- Highest wins: major > minor > patch. No feat/fix/perf/security/breaking change = no release needed (CC09).
- 1.0.0 and later: breaking -> X+1.0.0. 0.y.z: breaking -> 0.y+1.0 (CC10 reminds you to plan 1.0.0).
- Pre-release and build suffixes on --current are ignored for the bump; add them yourself (`3.0.0-rc.1`).
## Finding codes
| Code | Severity | Meaning |
|---|---|---|
| CC01 | medium | header is not a Conventional Commit (suggested header given) |
| CC02 | medium | unknown type (alias mapped or left out) |
| CC03 | high / info | breaking without BREAKING CHANGE footer (high); footer-only breaking (info) |
| CC04 | info / medium | revert cancelled in range (info); revert of an older release (medium) |
| CC05 | low | duplicate commit (cherry-pick) listed once |
| CC06 | low | style: upper-case type, trailing period, header over 72 chars |
| CC07 | medium | fixup/squash/WIP commit in the release range |
| CC08 | high | non-conforming commits that look like feat/fix are missing from the changelog |
| CC09 | info | no user-facing changes |
| CC10 | info | 0.x major-zero bump rule applied |
FILE:references/release-hygiene.md
# Release hygiene
## Writing entries users understand
- Describe the effect, not the implementation: "Export writes Parquet by default" beats "switch writer backend".
- Start with a verb in the imperative or a noun phrase, capitalise the first word, no trailing period. Keep one change per line.
- Keep scopes users know (cli, export, auth). Replace internal module names with the feature name.
- Merge several commits that describe one change into one entry and keep all links.
- Keep links: issue numbers for context, commit hashes for auditors, one compare link per version at the bottom.
## Migration notes for breaking changes
A good note answers three questions in one or two sentences:
1. What stops working? ("`tidyq export` without --format now writes .parquet")
2. Who is affected? ("scripts that expect a .csv file")
3. What do they do instead? ("pass --format csv")
Put every note in the BREAKING CHANGES block at the top of the release section and link a longer upgrade guide if needed.
## Keep a Changelog basics
- File `CHANGELOG.md`, newest version first, an `## [Unreleased]` section on top for work in progress.
- Version headings `## [3.0.0] - 2026-11-03` (ISO dates), sections in this order: Added, Changed, Deprecated, Removed, Fixed, Security. Leave out empty sections.
- Mark yanked releases `## [2.2.0] - 2026-09-01 [YANKED]`.
## Non-conforming history
- Do not rewrite pushed history to fix messages. Add the missing entries to the changelog by hand (the builder lists them with a suggested header).
- Squash fixup!/WIP commits before merging; with squash-merge, the PR title becomes the commit header, so lint PR titles.
- Cherry-picked commits on release branches appear twice in some ranges; list them once.
## CI checks worth adding
- commitlint (or a PR-title check) with the conventional config on every pull request.
- A release job that runs the builder on `last-tag..HEAD`, fails on CC03/CC08, and opens the changelog diff for review instead of committing it blindly.
- Tag only after the changelog PR is merged, so the tag points at a commit that contains its own notes.
FILE:templates/release-notes-review.md
# Release notes review: <project> <current> -> <next>
**Range:** <last tag>..<commit> **Date:** <YYYY-MM-DD> **Commits:** <n> (<conformance %> conforming)
## Version
<next> - <major/minor/patch> because <breaking change / new feature / fixes only>.
<Any reason to deviate, e.g. behaviour change typed as fix, 0.x rule>
## Breaking changes and migration
| Change | Who is affected | What to do |
|---|---|---|
| <change> | <users/scripts/integrations> | <migration step> |
## Changelog section (final, edited)
```markdown
## [<next>] - <date>
...
```
## Left out on purpose
| Commit | Subject | Why |
|---|---|---|
| <hash> | <subject> | <merge / revert / duplicate / internal / WIP> |
## Fixed before tagging
- [ ] Migration note written for every breaking change (CC03)
- [ ] Missing user-facing commits added by hand (CC08)
- [ ] Fixup/WIP commits squashed (CC07)
## Follow-ups
- <commitlint / PR title check / upgrade guide / announce deprecations>
FILE:examples/git-log.txt
commit 9f1c2a7e4b000000000000000000000000000000
Merge: 8e0b1d6 5b7e8a3
Author: Elif Demir <elif@tidyq.dev>
Date: Mon Nov 2 17:42:10 2026 +0300
Merge pull request #212 from tidyq/feat/parquet-export
commit 8e0b1d6c3a000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Mon Nov 2 16:05:31 2026 +0300
feat(export)!: write Parquet by default for `tidyq export`
Parquet is now the default output format. CSV is still available.
BREAKING CHANGE: `tidyq export` without --format now writes .parquet instead of .csv; pass --format csv to keep the old behaviour.
Refs: #198
commit 7d9a0c5b2f000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Mon Nov 2 11:20:04 2026 +0300
fixup! feat(export)!: write Parquet by default for `tidyq export`
commit 6c8f9b4a1e000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Sun Nov 1 20:14:55 2026 +0300
fix(auth): prevent API token leak in debug logs (#207)
Tokens were printed in full when TIDYQ_DEBUG=1. They are now masked.
Closes #205
commit 5b7e8a3f0d000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Sun Nov 1 15:02:13 2026 +0300
feat(query): support window functions in `--where` (#203)
commit 4a6d7f2e9c000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Sat Oct 31 18:47:39 2026 +0300
perf(reader): stream large CSV files instead of loading them into memory
Peak memory on a 2 GB file drops from 2.6 GB to 180 MB.
Closes #187
commit 3f5c6e1d8b000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Sat Oct 31 12:30:00 2026 +0300
Fixed crash when the config file is empty
commit 2e4b5d0c7a000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Fri Oct 30 19:11:42 2026 +0300
feat(cli): deprecate the --legacy-dates flag
It will be removed in 3.0. Use --date-format instead.
commit 1d3a4c9b6f000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Fri Oct 30 14:25:18 2026 +0300
Revert "feat(ui): colour themes for table output"
This reverts commit 0c2f3b8a5e000000000000000000000000000000.
Themes broke output on Windows terminals.
commit 0c2f3b8a5e000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Fri Oct 30 10:03:51 2026 +0300
feat(ui): colour themes for table output
commit fb1e2a7d4c000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Thu Oct 29 21:40:07 2026 +0300
feature: add `tidyq doctor` command to check the environment
commit ea0d196c3b000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Thu Oct 29 16:12:30 2026 +0300
fix(query): quote column names that contain spaces.
Closes #199
commit d9fc085b2a000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Thu Oct 29 09:55:02 2026 +0300
fix(config)!: drop support for the ~/.tidyqrc INI file
commit c8eb974a19000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Wed Oct 28 18:20:44 2026 +0300
docs: add a Parquet section to the README
commit b7da863f08000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Wed Oct 28 13:08:19 2026 +0300
chore(deps): bump pyarrow from 16.1.0 to 17.0.0
commit a6c9752e97000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Tue Oct 27 17:31:26 2026 +0300
ci: cache pip downloads in the test workflow
commit 95b8641d86000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Tue Oct 27 11:47:53 2026 +0300
test(reader): cover BOM and CRLF inputs
commit 84a7530c75000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Mon Oct 26 15:16:08 2026 +0300
fix(reader): handle UTF-8 BOM at the start of a file (#195)
commit 7396420b64000000000000000000000000000000
Author: Elif Demir <elif@tidyq.dev>
Date: Mon Oct 26 15:16:08 2026 +0300
fix(reader): handle UTF-8 BOM at the start of a file (#195)
commit 62853f9a53000000000000000000000000000000
Author: Sam Okafor <sam@tidyq.dev>
Date: Mon Oct 26 10:02:40 2026 +0300
WIP tweak table widths
commit 5174e8f942000000000000000000000000000000
Author: Mira Chen <mira@tidyq.dev>
Date: Sun Oct 25 19:44:12 2026 +0300
refactor(query): split the parser into tokenizer and planner
FILE:examples/CHANGELOG.md
# Changelog
All notable changes to tidyq are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.3.1] - 2026-10-21
### Fixed
- **reader:** Detect semicolon-separated files exported by Excel (#181)
## [2.3.0] - 2026-10-02
### Added
- **query:** `--limit` and `--offset` options (#170)
### Fixed
- **cli:** Exit with code 2 on unknown options instead of 1 (#174)
[2.3.1]: https://github.com/tidyq/tidyq/compare/v2.3.0...v2.3.1
[2.3.0]: https://github.com/tidyq/tidyq/compare/v2.2.0...v2.3.0
FILE:examples/changelog-report.md
# Changelog build: examples/git-log.txt
21 commits read: 17 Conventional (85.0% of non-merge commits), 1 non-conforming, 1 merges, 2 fixup/WIP.
**Version: 2.3.1 -> 3.0.0 (major bump, driven by breaking changes)**
**Verdict: 2 high finding(s) - fix before tagging**
## Release section (paste under the Unreleased heading of CHANGELOG.md)
```markdown
## [3.0.0] - 2026-11-03
### BREAKING CHANGES
- **export:** Write Parquet by default for `tidyq export` - `tidyq export` without --format now writes .parquet instead of .csv; pass --format csv to keep the old behaviour.
- **config:** Drop support for the ~/.tidyqrc INI file - MIGRATION NOTE MISSING
### Added
- **BREAKING** **export:** Write Parquet by default for `tidyq export` ([#198](https://github.com/tidyq/tidyq/issues/198), [8e0b1d6](https://github.com/tidyq/tidyq/commit/8e0b1d6))
- **query:** Support window functions in `--where` ([#203](https://github.com/tidyq/tidyq/issues/203), [5b7e8a3](https://github.com/tidyq/tidyq/commit/5b7e8a3))
- Add `tidyq doctor` command to check the environment ([fb1e2a7](https://github.com/tidyq/tidyq/commit/fb1e2a7))
### Changed
- **reader:** Stream large CSV files instead of loading them into memory ([#187](https://github.com/tidyq/tidyq/issues/187), [4a6d7f2](https://github.com/tidyq/tidyq/commit/4a6d7f2))
### Deprecated
- **cli:** Deprecate the --legacy-dates flag ([2e4b5d0](https://github.com/tidyq/tidyq/commit/2e4b5d0))
### Removed
- **BREAKING** **config:** Drop support for the ~/.tidyqrc INI file ([d9fc085](https://github.com/tidyq/tidyq/commit/d9fc085))
### Fixed
- **query:** Quote column names that contain spaces ([#199](https://github.com/tidyq/tidyq/issues/199), [ea0d196](https://github.com/tidyq/tidyq/commit/ea0d196))
- **reader:** Handle UTF-8 BOM at the start of a file ([#195](https://github.com/tidyq/tidyq/issues/195), [84a7530](https://github.com/tidyq/tidyq/commit/84a7530))
### Security
- **auth:** Prevent API token leak in debug logs ([#205](https://github.com/tidyq/tidyq/issues/205), [#207](https://github.com/tidyq/tidyq/issues/207), [6c8f9b4](https://github.com/tidyq/tidyq/commit/6c8f9b4))
[3.0.0]: https://github.com/tidyq/tidyq/compare/v2.3.1...v3.0.0
```
## Findings
- **HIGH CC03** d9fc085 'fix(config)!: drop support for the ~/.tidyqrc INI file' is breaking (!) but has no 'BREAKING CHANGE:' footer - add a migration note
- **HIGH CC08** 1 non-conforming commit(s) look user-facing (3f5c6e1) and are NOT in the changelog - reword them or add the entries by hand
- **MEDIUM CC01** 3f5c6e1 Fixed crash when the config file is empty: not a Conventional Commit - suggest: fix: crash when the config file is empty
- **MEDIUM CC02** fb1e2a7 feature: add `tidyq doctor` command to check the environment: unknown type 'feature' - treated as 'feat'
- **MEDIUM CC07** 7d9a0c5 'fixup! feat(export)!: write Parquet by default for `tidyq export`' is a fixup/WIP commit on the release branch - squash it before tagging
- **MEDIUM CC07** 62853f9 'WIP tweak table widths' is a fixup/WIP commit on the release branch - squash it before tagging
- **LOW CC05** 7396420 duplicates 84a7530 ('fix(reader): handle UTF-8 BOM at the start of a file (#195)', cherry-pick or re-applied?) - listed once
- **LOW CC06** ea0d196 fix(query): quote column names that contain spaces.: description ends with a period
- **INFO CC04** 1d3a4c9 reverts 0c2f3b8 'feat(ui): colour themes for table output' in the same range - both left out
## Not in the changelog
| Commit | Subject | Why |
|---|---|---|
| 9f1c2a7 | Merge pull request #212 from tidyq/feat/parquet-export | merge commit |
| 7d9a0c5 | fixup! feat(export)!: write Parquet by default for `tidyq export` | fixup/wip commit |
| 3f5c6e1 | Fixed crash when the config file is empty | not a Conventional Commit |
| 1d3a4c9 | Revert "feat(ui): colour themes for table output" | revert (cancelled) |
| 0c2f3b8 | feat(ui): colour themes for table output | reverted |
| 7396420 | fix(reader): handle UTF-8 BOM at the start of a file (#195) | duplicate |
| 62853f9 | WIP tweak table widths | fixup/wip commit |
| - | 5 internal commit(s): chore, ci, docs, refactor, test | internal type (use --include-internal to list) |
FILE:scripts/changelog_build.py
#!/usr/bin/env python3
"""Conventional Commits -> Keep a Changelog release section builder (stdlib only, Python 3.8+).
Reads a commit list (git log default output, git log --oneline, or a records file made with
git log --format='%H%x1f%s%x1f%b%x1e'), parses Conventional Commits headers, groups the changes
into Keep a Changelog sections, flags breaking changes and non-conforming commits, handles
reverts, merges and fixup commits, and suggests the next SemVer version.
Usage:
changelog_build.py COMMITS.txt [--current 1.4.2] [--date 2026-11-03] [--repo-url URL]
[--tag-prefix v] [--include-internal] [--insert CHANGELOG.md] [--json]
changelog_build.py --self-test
Exit: 0 = ok, 1 = high findings (breaking change without migration note, commits that would be
lost from the changelog), 2 = bad input.
"""
import argparse, datetime, json, re, sys
HEADER = re.compile(r"^(?P<type>[A-Za-z][A-Za-z0-9_-]*)(?:\((?P<scope>[^()\s][^()]*)\))?(?P<bang>!)?: (?P<desc>\S.*)$")
LOOSE = re.compile(r"^(?P<type>[A-Za-z][A-Za-z0-9_-]*)\s*(?:\((?P<scope>[^()]*)\))?\s*(?P<bang>!)?\s*:\s*(?P<desc>.*)$")
FOOTER = re.compile(r"^(?P<token>BREAKING[ -]CHANGE|[A-Za-z][A-Za-z-]*)(?:: | #)(?P<value>.+)$")
ISSUE = re.compile(r"(?:(?<=\s)|^|\()#(\d+)\b")
REVERTS = re.compile(r"This reverts commit ([0-9a-f]{7,40})", re.I)
KNOWN = {"feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore", "revert", "security", "deps"}
ALIASES = {"feature": "feat", "features": "feat", "add": "feat", "bugfix": "fix", "bug": "fix", "hotfix": "fix",
"fixes": "fix", "fixed": "fix", "doc": "docs", "documentation": "docs", "tests": "test",
"performance": "perf", "refactoring": "refactor", "cleanup": "chore", "release": "chore", "dep": "deps"}
INTERNAL = {"refactor", "docs", "style", "test", "build", "ci", "chore"}
SECTIONS = ["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]
SEV_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
class InputError(Exception):
pass
# ---------------------------------------------------------------- parsing input
def parse_commits(text):
"""Return list of dicts: hash, subject, body (oldest last, i.e. git log order)."""
text = text.replace("\r\n", "\n")
if "\x1e" in text:
out = []
for rec in text.split("\x1e"):
rec = rec.strip("\n")
if not rec.strip():
continue
parts = rec.split("\x1f")
if len(parts) < 2:
raise InputError("record without \\x1f separators: %r" % rec[:60])
out.append({"hash": parts[0].strip(), "subject": parts[1].strip(),
"body": (parts[2] if len(parts) > 2 else "").strip("\n")})
return out
lines = text.split("\n")
if any(re.match(r"^commit [0-9a-f]{7,40}\b", l) for l in lines):
out, cur, msg = [], None, []
def flush():
if cur is not None:
m = [l[4:] if l.startswith(" ") else l.strip() for l in msg]
while m and not m[0].strip():
m.pop(0)
while m and not m[-1].strip():
m.pop()
cur["subject"] = m[0].strip() if m else ""
cur["body"] = "\n".join(m[1:]).strip("\n")
out.append(cur)
for l in lines:
mm = re.match(r"^commit ([0-9a-f]{7,40})\b", l)
if mm:
flush()
cur, msg = {"hash": mm.group(1), "merge": False}, []
elif cur is not None and not msg and re.match(r"^(Merge|Author|AuthorDate|Commit|CommitDate|Date):", l):
if l.startswith("Merge:"):
cur["merge"] = True
if l.startswith("Author:"):
cur["author"] = l[7:].strip()
if l.startswith("Date:"):
cur["date"] = l[5:].strip()
elif cur is not None:
msg.append(l)
flush()
return out
out = []
for l in lines:
if not l.strip() or l.lstrip().startswith("#"):
continue
mm = re.match(r"^\s*(?:\*\s*)?([0-9a-f]{7,40})\s+(.*)$", l)
if mm:
out.append({"hash": mm.group(1), "subject": mm.group(2).strip(), "body": ""})
else:
out.append({"hash": "", "subject": l.strip(), "body": ""})
return out
def parse_footers(body):
"""Footers = trailing paragraph lines 'Token: value' / 'Token #value' / 'BREAKING CHANGE: ...'."""
feet, breaking = [], None
paras = re.split(r"\n\s*\n", body.strip()) if body.strip() else []
if paras:
last = paras[-1].split("\n")
if FOOTER.match(last[0]):
cur = None
for l in last:
m = FOOTER.match(l)
if m:
cur = [m.group("token"), m.group("value").strip()]
feet.append(cur)
elif cur:
cur[1] += " " + l.strip()
for tok, val in feet:
if tok in ("BREAKING CHANGE", "BREAKING-CHANGE"):
breaking = val
if breaking is None: # BREAKING CHANGE paragraph anywhere in the body
m = re.search(r"^BREAKING[ -]CHANGE: (.+?)(?:\n\s*\n|\Z)", body, re.M | re.S)
if m:
breaking = " ".join(m.group(1).split())
return feet, breaking
def classify(c):
"""Annotate one commit dict in place."""
s = c["subject"]
c["issues"] = sorted(set(ISSUE.findall(s + "\n" + c.get("body", ""))), key=int)
c["problems"] = []
if c.get("merge") or re.match(r"^Merge (pull request|branch|remote-tracking branch|tag) ", s):
c["kind"] = "merge"; return c
m = re.match(r'^Revert "(.+)"$', s)
if m:
c["kind"] = "revert"; c["reverted_subject"] = m.group(1)
r = REVERTS.search(c.get("body", "")); c["reverted_hash"] = r.group(1) if r else None
return c
if re.match(r"^(fixup|squash|amend)! ", s) or re.match(r"^(wip|WIP)\b", s):
c["kind"] = "fixup"; return c
m = HEADER.match(s)
feet, breaking = parse_footers(c.get("body", ""))
c["footers"] = feet
if m:
t = m.group("type")
c.update(type=t.lower(), scope=m.group("scope"), bang=bool(m.group("bang")), desc=m.group("desc").strip(),
breaking_note=breaking, kind="cc")
if t != t.lower():
c["problems"].append(("low", "CC06", "type '%s' should be lower case" % t))
if c["type"] not in KNOWN:
alias = ALIASES.get(c["type"])
c["problems"].append(("medium", "CC02", "unknown type '%s'%s" % (t, (" - treated as '%s'" % alias) if alias else " - left out of the changelog")))
if alias:
c["type"] = alias
else:
c["kind"] = "unknown"
if c["type"] == "revert":
c["kind"] = "revert"; c["reverted_subject"] = c["desc"]
r = REVERTS.search(c.get("body", "")); c["reverted_hash"] = r.group(1) if r else None
return c
d = c["desc"]
if d.endswith("."):
c["problems"].append(("low", "CC06", "description ends with a period"))
if len(s) > 72:
c["problems"].append(("low", "CC06", "header is %d chars (keep <= 72)" % len(s)))
c["breaking"] = c["bang"] or breaking is not None
return c
lm = LOOSE.match(s)
c["kind"] = "nonconforming"
if lm and lm.group("desc"):
t = lm.group("type").lower(); t = ALIASES.get(t, t)
fixed = "%s%s%s: %s" % (t, "(%s)" % lm.group("scope").strip() if lm.group("scope") else "", "!" if lm.group("bang") else "", lm.group("desc").strip())
c["suggest"] = fixed
c["problems"].append(("medium", "CC01", "malformed header (spacing/colon) - suggest: %s" % fixed))
else:
c["suggest"] = guess_header(s)
c["problems"].append(("medium", "CC01", "not a Conventional Commit - suggest: %s" % c["suggest"]))
return c
def guess_header(s):
low = s.lower()
if re.match(r"^(fix|fixed|fixes|resolve|resolved|correct)", low) or "bug" in low or "typo" in low:
t = "docs" if re.search(r"readme|docs?\b|typo", low) else "fix"
elif re.match(r"^(add|added|implement|introduce|support|new)", low):
t = "feat"
elif re.match(r"^(update|bump|upgrade)", low) and re.search(r"dep|version|package|lock", low):
t = "chore(deps)"
elif re.match(r"^(remove|delete|drop)", low):
t = "refactor"
elif re.search(r"test", low):
t = "test"
else:
t = "chore"
if t == "fix":
s = re.sub(r"^(fixed|fixes|fix)\s+", "", s, flags=re.I)
d = re.sub(r"^(fixed|fixes|added|adds|updated|updates|removed|removes)\b", lambda m: {"fixed": "fix", "fixes": "fix", "added": "add", "adds": "add", "updated": "update", "updates": "update", "removed": "remove", "removes": "remove"}[m.group(1).lower()], s, flags=re.I)
d = d[0].lower() + d[1:] if d else d
return "%s: %s" % (t, d.rstrip("."))
def section_for(c):
d = c["desc"].lower()
if c["type"] == "security" or (c["type"] in ("fix", "deps") and re.search(r"\b(cve-\d|security|vulnerab|xss|csrf|injection|leak)", d)):
return "Security"
if re.search(r"\bdeprecat", d):
return "Deprecated"
if re.match(r"(remove|drop|delete)\b", d) and (c["type"] == "feat" or c.get("breaking")):
return "Removed"
if c["type"] == "feat":
return "Added"
if c["type"] == "fix":
return "Fixed"
if c["type"] in ("perf", "deps"):
return "Changed"
if c.get("breaking"):
return "Changed"
return None
# ---------------------------------------------------------------- analysis
def bump_version(current, level):
m = re.match(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$", current or "")
if not m:
return None
a, b, c = map(int, m.groups())
if level == "major":
return "%d.0.0" % (a + 1) if a > 0 else "0.%d.0" % (b + 1)
if level == "minor":
return "%d.%d.0" % (a, b + 1)
if level == "patch":
return "%d.%d.%d" % (a, b, c + 1)
return None
def analyze(commits, current=None, include_internal=False):
for c in commits:
classify(c)
findings = []
# reverts: cancel out when the reverted commit is in range
by_hash = {c["hash"][:7]: c for c in commits if c.get("hash")}
by_subject = {}
for c in commits:
by_subject.setdefault(c["subject"], []).append(c)
for c in commits:
if c.get("kind") != "revert":
continue
tgt = None
if c.get("reverted_hash"):
tgt = by_hash.get(c["reverted_hash"][:7])
if tgt is None and c.get("reverted_subject") in by_subject:
tgt = by_subject[c["reverted_subject"]][-1]
if tgt is not None and tgt is not c:
tgt["reverted_by"] = c["hash"][:7] or c["subject"]
c["cancels"] = True
findings.append(("info", "CC04", "%s reverts %s '%s' in the same range - both left out" % (c["hash"][:7] or "?", tgt["hash"][:7] or "?", tgt["subject"])))
else:
c["cancels"] = False
findings.append(("medium", "CC04", "%s reverts '%s' from an earlier release - listed under Removed/Fixed; check the wording" % (c["hash"][:7] or "?", c.get("reverted_subject"))))
seen = {}
for c in commits:
if c.get("kind") == "cc":
key = (c["type"], (c.get("scope") or "").lower(), c["desc"].lower().rstrip("."))
if key in seen:
c["duplicate_of"] = seen[key]
findings.append(("low", "CC05", "%s duplicates %s ('%s', cherry-pick or re-applied?) - listed once" % (c["hash"][:7] or "?", seen[key] or "?", c["subject"])))
else:
seen[key] = c["hash"][:7]
sections = {s: [] for s in SECTIONS}
internal, breaking, skipped = [], [], []
level = None
rank = {"patch": 1, "minor": 2, "major": 3}
def up(l):
nonlocal level
if level is None or rank[l] > rank[level]:
level = l
for c in commits:
k = c.get("kind")
for sev, code, msg in c["problems"]:
findings.append((sev, code, "%s %s: %s" % (c["hash"][:7] or "-", c["subject"], msg)))
if k == "merge":
skipped.append((c, "merge commit")); continue
if k == "fixup":
skipped.append((c, "fixup/wip commit"))
findings.append(("medium", "CC07", "%s '%s' is a fixup/WIP commit on the release branch - squash it before tagging" % (c["hash"][:7] or "-", c["subject"])))
continue
if c.get("reverted_by") or (k == "revert" and c.get("cancels")):
skipped.append((c, "reverted" if c.get("reverted_by") else "revert (cancelled)")); continue
if c.get("duplicate_of"):
skipped.append((c, "duplicate")); continue
if k == "revert":
entry = {"text": "Revert: " + (c.get("reverted_subject") or c["subject"]), "hash": c["hash"][:7], "issues": c["issues"], "scope": None}
sections["Removed" if (c.get("reverted_subject") or "").startswith("feat") else "Fixed"].append(entry)
up("patch"); continue
if k in ("nonconforming", "unknown"):
skipped.append((c, "not a Conventional Commit")); continue
sec = section_for(c)
entry = {"text": c["desc"], "hash": c["hash"][:7], "issues": c["issues"], "scope": c.get("scope"),
"breaking": c.get("breaking", False), "type": c["type"]}
if c.get("breaking"):
up("major")
breaking.append({"hash": c["hash"][:7], "scope": c.get("scope"), "desc": c["desc"], "note": c.get("breaking_note")})
if not c.get("breaking_note"):
findings.append(("high", "CC03", "%s '%s' is breaking (!) but has no 'BREAKING CHANGE:' footer - add a migration note" % (c["hash"][:7] or "-", c["subject"])))
elif not c["bang"]:
findings.append(("info", "CC03", "%s '%s' is breaking via footer only - consider '!' in the header so it shows in one-line logs" % (c["hash"][:7] or "-", c["subject"])))
if sec:
sections[sec].append(entry)
if c["type"] == "feat":
up("minor")
elif c["type"] in ("fix", "perf", "security", "deps"):
up("patch")
else:
internal.append(entry)
if include_internal:
up("patch")
lost = [c for c, why in skipped if why == "not a Conventional Commit"]
lost_userfacing = [c for c in lost if re.match(r"^(feat|fix|perf|security)", c.get("suggest") or "")]
if lost_userfacing:
findings.append(("high", "CC08", "%d non-conforming commit(s) look user-facing (%s) and are NOT in the changelog - reword them or add the entries by hand" % (len(lost_userfacing), ", ".join(c["hash"][:7] or c["subject"][:20] for c in lost_userfacing))))
if not any(sections.values()) and not breaking:
findings.append(("info", "CC09", "no user-facing changes (only internal commits) - a release is optional"))
nxt = bump_version(current, level) if current else None
if current and level == "major" and re.match(r"^v?0\.", current):
findings.append(("info", "CC10", "version is 0.x: breaking changes bump the minor (%s); go to 1.0.0 once the API is stable" % nxt))
if current and not re.match(r"^v?\d+\.\d+\.\d+", current):
raise InputError("--current must look like 1.4.2, got %r" % current)
findings.sort(key=lambda f: (SEV_ORDER[f[0]], f[1]))
stats = {"commits": len(commits),
"conventional": sum(1 for c in commits if c.get("kind") in ("cc", "revert")),
"nonconforming": sum(1 for c in commits if c.get("kind") in ("nonconforming", "unknown")),
"merges": sum(1 for c in commits if c.get("kind") == "merge"),
"fixups": sum(1 for c in commits if c.get("kind") == "fixup")}
checkable = len(commits) - stats["merges"]
stats["conformance_pct"] = round(100.0 * stats["conventional"] / checkable, 1) if checkable else 100.0
return {"sections": sections, "internal": internal, "breaking": breaking, "skipped": skipped,
"level": level, "current": current, "next": nxt, "findings": findings, "stats": stats, "commits": commits}
# ---------------------------------------------------------------- rendering
def clean_text(t):
"""Drop a trailing '(#123)' (it is linked separately) and a trailing period."""
t = re.sub(r"\s*\((?:#\d+(?:,\s*)?)+\)\s*$", "", t).rstrip()
return t[:-1] if t.endswith(".") and not t.endswith("..") else t
def fmt_entry(e, repo_url=None):
t = clean_text(e["text"])
t = t[0].upper() + t[1:] if t else t
s = ("**%s:** " % e["scope"] if e.get("scope") else "") + t
if e.get("breaking"):
s = "**BREAKING** " + s
refs = []
for i in e.get("issues") or []:
refs.append("[#%s](%s/issues/%s)" % (i, repo_url, i) if repo_url else "#%s" % i)
if e.get("hash"):
refs.append("[%s](%s/commit/%s)" % (e["hash"], repo_url, e["hash"]) if repo_url else e["hash"])
return "- " + s + (" (" + ", ".join(refs) + ")" if refs else "")
def release_section(r, date=None, repo_url=None, tag_prefix="v", include_internal=False):
ver = r["next"] or "Unreleased"
head = "## [%s] - %s" % (ver, date or datetime.date.today().isoformat()) if r["next"] else "## [Unreleased]"
out = [head, ""]
if r["breaking"]:
out += ["### BREAKING CHANGES", ""]
for b in r["breaking"]:
d = clean_text(b["desc"])
line = "- " + ("**%s:** " % b["scope"] if b["scope"] else "") + d[0].upper() + d[1:]
line += " - " + b["note"] if b["note"] else " - MIGRATION NOTE MISSING"
out.append(line)
out.append("")
for s in SECTIONS:
items = sorted(r["sections"][s], key=lambda e: (not e.get("breaking"), (e.get("scope") or "~").lower()))
if items:
out += ["### " + s, ""] + [fmt_entry(e, repo_url) for e in items] + [""]
if include_internal and r["internal"]:
out += ["### Internal", ""] + [fmt_entry(e, repo_url) for e in r["internal"]] + [""]
if r["next"] and repo_url and r["current"]:
out.append("[%s]: %s/compare/%s%s...%s%s" % (ver, repo_url, tag_prefix, r["current"].lstrip("v"), tag_prefix, ver))
out.append("")
return "\n".join(out).rstrip() + "\n"
def insert_into(changelog_text, section, version):
if version and re.search(r"^## \[%s\]" % re.escape(version), changelog_text, re.M):
raise InputError("CHANGELOG already has a section for %s" % version)
lines = changelog_text.split("\n")
for i, l in enumerate(lines):
if re.match(r"^## \[Unreleased\]", l, re.I):
j = i + 1
while j < len(lines) and not lines[j].startswith("## "):
j += 1
leftover = [x for x in lines[i + 1:j] if x.strip()]
new = lines[:i + 1] + [""] + section.rstrip("\n").split("\n") + [""] + lines[j:]
return "\n".join(new), leftover
for i, l in enumerate(lines):
if l.startswith("## "):
return "\n".join(lines[:i] + section.rstrip("\n").split("\n") + [""] + lines[i:]), []
return changelog_text.rstrip("\n") + "\n\n" + section, []
def report(r, args):
st = r["stats"]
high = [f for f in r["findings"] if f[0] == "high"]
out = ["# Changelog build: %s" % args.commits, ""]
out.append("%d commits read: %d Conventional (%.1f%% of non-merge commits), %d non-conforming, %d merges, %d fixup/WIP." % (
st["commits"], st["conventional"], st["conformance_pct"], st["nonconforming"], st["merges"], st["fixups"]))
out.append("")
if r["current"]:
out.append("**Version: %s -> %s (%s bump%s)**" % (r["current"], r["next"] or r["current"], r["level"] or "no",
", driven by " + {"major": "breaking changes", "minor": "new features", "patch": "fixes/perf"}[r["level"]] if r["level"] else ""))
else:
out.append("**Suggested bump: %s** (pass --current X.Y.Z for the version number)" % (r["level"] or "none"))
out.append("")
out.append("**Verdict: %s**" % ("%d high finding(s) - fix before tagging" % len(high) if high else "ready to tag"))
out += ["", "## Release section (paste under the Unreleased heading of CHANGELOG.md)", "", "```markdown",
release_section(r, args.date, args.repo_url, args.tag_prefix, args.include_internal).rstrip(), "```", ""]
out += ["## Findings", ""]
if r["findings"]:
out += ["- **%s %s** %s" % (s.upper(), c, m) for s, c, m in r["findings"]]
else:
out.append("- none")
out += ["", "## Not in the changelog", "", "| Commit | Subject | Why |", "|---|---|---|"]
for c, why in r["skipped"]:
out.append("| %s | %s | %s |" % (c["hash"][:7] or "-", c["subject"].replace("|", "\\|"), why))
if r["internal"] and not args.include_internal:
out.append("| - | %d internal commit(s): %s | internal type (use --include-internal to list) |" % (
len(r["internal"]), ", ".join(sorted({e["type"] for e in r["internal"]}))))
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------- self-test
def self_test():
tests = []
def t(name, cond):
tests.append((name, bool(cond)))
def run(txt, cur="1.4.2"):
return analyze(parse_commits(txt), cur)
r = run("a1b2c3d feat(api): add export endpoint\nb2c3d4e fix: handle empty list\n")
t("oneline parse + feat -> minor", r["next"] == "1.5.0" and len(r["sections"]["Added"]) == 1)
t("fix -> Fixed section", r["sections"]["Fixed"][0]["text"] == "handle empty list")
t("entry text drops trailing (#12) and period", fmt_entry({"text": "quote names. (#12)", "issues": ["12"], "hash": "abc1234"}) == "- Quote names (#12, abc1234)")
r = run("a1b2c3d fix: x\n")
t("fix only -> patch", r["next"] == "1.4.3")
r = run("a1b2c3d feat!: drop node 16\n")
t("bang breaking -> major", r["next"] == "2.0.0" and r["breaking"])
t("bang without footer is high CC03", any(f[1] == "CC03" and f[0] == "high" for f in r["findings"]))
t("feat removing -> Removed", r["sections"]["Removed"])
rec = "abc1234\x1ffeat(cli): new flag\x1fBody text.\n\nBREAKING CHANGE: --old is gone, use --new\x1e"
r = run(rec)
t("records format + footer breaking", r["breaking"] and r["breaking"][0]["note"].startswith("--old"))
t("footer-only breaking is info not high", not any(f[0] == "high" for f in r["findings"]))
t("0.x breaking -> minor", bump_version("0.9.3", "major") == "0.10.0")
r = run("a1b2c3d Fixed crash when saving\nb2c3d4e chore: tidy\n")
t("non-conforming flagged CC01", any(f[1] == "CC01" for f in r["findings"]))
t("suggest drops 'Fixed' verb", any("suggest: fix: crash when saving" in f[2] for f in r["findings"]))
t("user-facing non-conforming is high CC08", any(f[1] == "CC08" and f[0] == "high" for f in r["findings"]))
r = run("a1b2c3d feature: dark mode\n")
t("alias feature -> feat", r["sections"]["Added"] and any(f[1] == "CC02" for f in r["findings"]))
r = run("a1b2c3d fix:missing space\n")
t("malformed spacing suggestion", any("suggest: fix: missing space" in f[2] for f in r["findings"]))
log = ("commit 1111111aaaa\nAuthor: A <a@x>\nDate: Mon\n\n Revert \"feat: shiny\"\n\n This reverts commit 2222222bbbb.\n\n"
"commit 2222222bbbb\nAuthor: A <a@x>\nDate: Mon\n\n feat: shiny\n")
r = run(log)
t("revert in range cancels both", not any(r["sections"].values()) and any(f[1] == "CC04" for f in r["findings"]))
r = run("commit 3333333\nMerge: 1 2\nAuthor: A\nDate: x\n\n Merge pull request #5 from a/b\n\ncommit 4444444\nAuthor: A\nDate: x\n\n fixup! fix: y\n")
t("merge skipped, fixup flagged CC07", r["stats"]["merges"] == 1 and any(f[1] == "CC07" for f in r["findings"]))
r = run("a1b2c3d fix(auth): prevent token leak in logs (#41)\n")
t("security keyword -> Security + issue ref", r["sections"]["Security"] and r["sections"]["Security"][0]["issues"] == ["41"])
r = run("a1b2c3d feat(cfg): deprecate legacy yaml keys\n")
t("deprecate -> Deprecated", r["sections"]["Deprecated"])
r = run("a1b2c3d docs: readme\nb2c3d4e ci: cache\n")
t("internal only -> no bump + CC09", r["level"] is None and any(f[1] == "CC09" for f in r["findings"]))
r = run("a1b2c3d fix: same\nb2c3d4e fix: same\n")
t("duplicate listed once", len(r["sections"]["Fixed"]) == 1)
sec = release_section(run("a1b2c3d feat: x\n"), "2026-11-03", "https://github.com/o/r")
t("compare link + date", "## [1.5.0] - 2026-11-03" in sec and "compare/v1.4.2...v1.5.0" in sec)
new, left = insert_into("# Changelog\n\n## [Unreleased]\n\n## [1.4.2] - 2026-10-01\n- a\n", sec, "1.5.0")
t("insert after Unreleased", new.index("## [Unreleased]") < new.index("## [1.5.0]") < new.index("## [1.4.2]"))
try:
insert_into("## [1.5.0] - x\n", sec, "1.5.0"); ok = False
except InputError:
ok = True
t("insert refuses existing version", ok)
try:
run("a fix: x\n", cur="one"); ok = False
except InputError:
ok = True
t("bad --current rejected", ok)
for name, okk in tests:
print(("PASS " if okk else "FAIL ") + name)
n = sum(1 for _, okk in tests if okk)
print("%d/%d passed" % (n, len(tests)))
return 0 if n == len(tests) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("commits", nargs="?")
ap.add_argument("--current", help="current released version, e.g. 1.4.2")
ap.add_argument("--date", help="release date (default today)")
ap.add_argument("--repo-url", help="https://github.com/owner/repo for commit, issue and compare links")
ap.add_argument("--tag-prefix", default="v")
ap.add_argument("--include-internal", action="store_true", help="also list refactor/docs/ci/chore/test commits")
ap.add_argument("--insert", metavar="CHANGELOG.md", help="print the changelog with the new section inserted (file is not modified)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.commits:
ap.error("commits file required (or --self-test)")
try:
if a.date and not re.match(r"^\d{4}-\d{2}-\d{2}$", a.date):
raise InputError("--date must be YYYY-MM-DD")
try:
text = sys.stdin.read() if a.commits == "-" else open(a.commits, encoding="utf-8").read()
except OSError as e:
raise InputError(str(e))
commits = parse_commits(text)
if not commits:
raise InputError("no commits found in %s" % a.commits)
r = analyze(commits, a.current, a.include_internal)
if a.insert:
try:
old = open(a.insert, encoding="utf-8").read()
except OSError as e:
raise InputError(str(e))
new, leftover = insert_into(old, release_section(r, a.date, a.repo_url, a.tag_prefix, a.include_internal), r["next"])
if leftover:
sys.stderr.write("note: %d hand-written line(s) under [Unreleased] were kept below the new section - merge them in\n" % len(leftover))
sys.stdout.write(new if new.endswith("\n") else new + "\n")
elif a.json:
j = {k: r[k] for k in ("sections", "internal", "breaking", "level", "current", "next", "stats")}
j["findings"] = [{"severity": s, "code": c, "message": m} for s, c, m in r["findings"]]
j["skipped"] = [{"hash": c["hash"][:7], "subject": c["subject"], "why": w} for c, w in r["skipped"]]
j["release_section"] = release_section(r, a.date, a.repo_url, a.tag_prefix, a.include_internal)
print(json.dumps(j, indent=2))
else:
print(report(r, a))
except InputError as e:
print("error: %s" % e, file=sys.stderr)
return 2
return 1 if any(f[0] == "high" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Most Contributed
I want to create a 10 min. YouTube video which contain a voiceover script, footage, diagram, image, graph and short text.
why do we procrastinate? why do I procrastinate? Procrastination psychology, psychology of procrastination, why we procrastinate, procrastination explained, procrastination and motivation, fear of failure, perfectionism and procrastination, emotional avoidance, how to stop procrastinating, psychology explained, human behaviour, social psychology, behavioural psychology, motivation psychology, productivity psychology, why we behave, everyday psychology

Transform famous brands into adorable, 3D chibi-style concept stores. This prompt blends iconic product designs with miniature architecture, creating a cozy 'blind-box' toy aesthetic perfect for playful visualizations.
3D chibi-style miniature concept store of Mc Donalds, creatively designed with an exterior inspired by the brand's most iconic product or packaging (such as a giant chicken bucket, hamburger, donut, roast duck). The store features two floors with large glass windows clearly showcasing the cozy and finely decorated interior: {brand's primary color}-themed decor, warm lighting, and busy staff dressed in outfits matching the brand. Adorable tiny figures stroll or sit along the street, surrounded by benches, street lamps, and potted plants, creating a charming urban scene. Rendered in a miniature cityscape style using Cinema 4D, with a blind-box toy aesthetic, rich in details and realism, and bathed in soft lighting that evokes a relaxing afternoon atmosphere. --ar 2:3 Brand name: Mc Donalds
Generate a BI-style revenue report with SQL, covering MRR, ARR, churn, and active subscriptions using AI2sql.
Generate a monthly revenue performance report showing MRR, number of active subscriptions, and churned subscriptions for the last 6 months, grouped by month.

Upload your photo, type the footballer’s name, and choose a team for the jersey they hold. The scene is generated in front of the stands filled with the footballer’s supporters, while the held jersey stays consistent with your selected team’s official colors and design.
Inputs Reference 1: User’s uploaded photo Reference 2: Footballer Name Jersey Number: Jersey Number Jersey Team Name: Jersey Team Name (team of the jersey being held) User Outfit: User Outfit Description Mood: Mood Prompt Create a photorealistic image of the person from the user’s uploaded photo standing next to Footballer Name pitchside in front of the stadium stands, posing for a photo. Location: Pitchside/touchline in a large stadium. Natural grass and advertising boards look realistic. Stands: The background stands must feel 100% like Footballer Name’s team home crowd (single-team atmosphere). Dominant team colors, scarves, flags, and banners. No rival-team colors or mixed sections visible. Composition: Both subjects centered, shoulder to shoulder. Footballer Name can place one arm around the user. Prop: They are holding a jersey together toward the camera. The back of the jersey must clearly show Footballer Name and the number Jersey Number. Print alignment is clean, sharp, and realistic. Critical rule (lock the held jersey to a specific team) The jersey they are holding must be an official kit design of Jersey Team Name. Keep the jersey colors, patterns, and overall design consistent with Jersey Team Name. If the kit normally includes a crest and sponsor, place them naturally and realistically (no distorted logos or random text). Prevent color drift: the jersey’s primary and secondary colors must stay true to Jersey Team Name’s known colors. Note: Jersey Team Name must not be the club Footballer Name currently plays for. Clothing: Footballer Name: Wearing his current team’s match kit (shirt, shorts, socks), looks natural and accurate. User: User Outfit Description Camera: Eye level, 35mm, slight wide angle, natural depth of field. Focus on the two people, background slightly blurred. Lighting: Stadium lighting + daylight (or evening match lights), realistic shadows, natural skin tones. Faces: Keep the user’s face and identity faithful to the uploaded reference. Footballer Name is clearly recognizable. Expression: Mood Quality: Ultra realistic, natural skin texture and fabric texture, high resolution. Negative prompts Wrong team colors on the held jersey, random or broken logos/text, unreadable name/number, extra limbs/fingers, facial distortion, watermark, heavy blur, duplicated crowd faces, oversharpening. Output Single image, 3:2 landscape or 1:1 square, high resolution.
This prompt is designed for an elite frontend development specialist. It outlines responsibilities and skills required for building high-performance, responsive, and accessible user interfaces using modern JavaScript frameworks such as React, Vue, Angular, and more. The prompt includes detailed guidelines for component architecture, responsive design, performance optimization, state management, and UI/UX implementation, ensuring the creation of delightful user experiences.
# Frontend Developer You are an elite frontend development specialist with deep expertise in modern JavaScript frameworks, responsive design, and user interface implementation. Your mastery spans React, Vue, Angular, and vanilla JavaScript, with a keen eye for performance, accessibility, and user experience. You build interfaces that are not just functional but delightful to use. Your primary responsibilities: 1. **Component Architecture**: When building interfaces, you will: - Design reusable, composable component hierarchies - Implement proper state management (Redux, Zustand, Context API) - Create type-safe components with TypeScript - Build accessible components following WCAG guidelines - Optimize bundle sizes and code splitting - Implement proper error boundaries and fallbacks 2. **Responsive Design Implementation**: You will create adaptive UIs by: - Using mobile-first development approach - Implementing fluid typography and spacing - Creating responsive grid systems - Handling touch gestures and mobile interactions - Optimizing for different viewport sizes - Testing across browsers and devices 3. **Performance Optimization**: You will ensure fast experiences by: - Implementing lazy loading and code splitting - Optimizing React re-renders with memo and callbacks - Using virtualization for large lists - Minimizing bundle sizes with tree shaking - Implementing progressive enhancement - Monitoring Core Web Vitals 4. **Modern Frontend Patterns**: You will leverage: - Server-side rendering with Next.js/Nuxt - Static site generation for performance - Progressive Web App features - Optimistic UI updates - Real-time features with WebSockets - Micro-frontend architectures when appropriate 5. **State Management Excellence**: You will handle complex state by: - Choosing appropriate state solutions (local vs global) - Implementing efficient data fetching patterns - Managing cache invalidation strategies - Handling offline functionality - Synchronizing server and client state - Debugging state issues effectively 6. **UI/UX Implementation**: You will bring designs to life by: - Pixel-perfect implementation from Figma/Sketch - Adding micro-animations and transitions - Implementing gesture controls - Creating smooth scrolling experiences - Building interactive data visualizations - Ensuring consistent design system usage **Framework Expertise**: - React: Hooks, Suspense, Server Components - Vue 3: Composition API, Reactivity system - Angular: RxJS, Dependency Injection - Svelte: Compile-time optimizations - Next.js/Remix: Full-stack React frameworks **Essential Tools & Libraries**: - Styling: Tailwind CSS, CSS-in-JS, CSS Modules - State: Redux Toolkit, Zustand, Valtio, Jotai - Forms: React Hook Form, Formik, Yup - Animation: Framer Motion, React Spring, GSAP - Testing: Testing Library, Cypress, Playwright - Build: Vite, Webpack, ESBuild, SWC **Performance Metrics**: - First Contentful Paint < 1.8s - Time to Interactive < 3.9s - Cumulative Layout Shift < 0.1 - Bundle size < 200KB gzipped - 60fps animations and scrolling **Best Practices**: - Component composition over inheritance - Proper key usage in lists - Debouncing and throttling user inputs - Accessible form controls and ARIA labels - Progressive enhancement approach - Mobile-first responsive design Your goal is to create frontend experiences that are blazing fast, accessible to all users, and delightful to interact with. You understand that in the 6-day sprint model, frontend code needs to be both quickly implemented and maintainable. You balance rapid development with code quality, ensuring that shortcuts taken today don't become technical debt tomorrow.
Knowledge Parcer
# ROLE: PALADIN OCTEM (Competitive Research Swarm) ## 🏛️ THE PRIME DIRECTIVE You are not a standard assistant. You are **The Paladin Octem**, a hive-mind of four rival research agents presided over by **Lord Nexus**. Your goal is not just to answer, but to reach the Truth through *adversarial conflict*. ## 🧬 THE RIVAL AGENTS (Your Search Modes) When I submit a query, you must simulate these four distinct personas accessing Perplexity's search index differently: 1. **[⚡] VELOCITY (The Sprinter)** * **Search Focus:** News, social sentiment, events from the last 24-48 hours. * **Tone:** "Speed is truth." Urgent, clipped, focused on the *now*. * **Goal:** Find the freshest data point, even if unverified. 2. **[📜] ARCHIVIST (The Scholar)** * **Search Focus:** White papers, .edu domains, historical context, definitions. * **Tone:** "Context is king." Condescending, precise, verbose. * **Goal:** Find the deepest, most cited source to prove Velocity wrong. 3. **[👁️] SKEPTIC (The Debunker)** * **Search Focus:** Criticisms, "debunking," counter-arguments, conflict of interest checks. * **Tone:** "Trust nothing." Cynical, sharp, suspicious of "hype." * **Goal:** Find the fatal flaw in the premise or the data. 4. **[🕸️] WEAVER (The Visionary)** * **Search Focus:** Lateral connections, adjacent industries, long-term implications. * **Tone:** "Everything is connected." Abstract, metaphorical. * **Goal:** Connect the query to a completely different field. --- ## ⚔️ THE OUTPUT FORMAT (Strict) For every query, you must output your response in this exact Markdown structure: ### 🏆 PHASE 1: THE TROPHY ROOM (Findings) *(Run searches for each agent and present their best finding)* * **[⚡] VELOCITY:** "key_finding_from_recent_news. This is the bleeding edge." (*Citations*) * **[📜] ARCHIVIST:** "Ignore the noise. The foundational text states [Historical/Technical Fact]." (*Citations*) * **[👁️] SKEPTIC:** "I found a contradiction. [Counter-evidence or flaw in the popular narrative]." (*Citations*) * **[🕸️] WEAVER:** "Consider the bigger picture. This links directly to unexpected_concept." (*Citations*) ### 🗣️ PHASE 2: THE CLASH (The Debate) *(A short dialogue where the agents attack each other's findings based on their philosophies)* * *Example: Skeptic attacks Velocity's source for being biased; Archivist dismisses Weaver as speculative.* ### ⚖️ PHASE 3: THE VERDICT (Lord Nexus) *(The Final Synthesis)* **LORD NEXUS:** "Enough. I have weighed the evidence." * **The Reality:** synthesis_of_truth * **The Warning:** valid_point_from_skeptic * **The Prediction:** [Insight from Weaver/Velocity] --- ## 🚀 ACKNOWLEDGE If you understand these protocols, reply only with: "**THE OCTEM IS LISTENING. THROW ME A QUERY.**" OS/Digital DECLUTTER via CLI
I want you to act as a web design consultant. I will provide details about an organization that needs assistance designing or redesigning a website. Your role is to analyze these details and recommend the most suitable information architecture, visual design, and interactive features that enhance user experience while aligning with the organization’s business goals. You should apply your knowledge of UX/UI design principles, accessibility standards, web development best practices, and modern front-end technologies to produce a clear, structured, and actionable project plan. This may include layout suggestions, component structures, design system guidance, and feature recommendations. My first request is: “I need help creating a white page that showcases courses, including course listings, brief descriptions, instructor highlights, and clear calls to action.”
I want you to act as an interviewer. I will be the candidate and you will ask me the interview questions for the Software Developer position. I want you to only reply as the interviewer. Do not write all the conversation at once. I want you to only do the interview with me. Ask me the questions and wait for my answers. Do not write explanations. Ask me the questions one by one like an interviewer does and wait for my answers.
My first sentence is "Hi"
This prompt provides a detailed photorealistic description for generating a selfie portrait of a young female subject. It includes specifics on demographics, facial features, body proportions, clothing, pose, setting, camera details, lighting, mood, and style. The description is intended for use in creating high-fidelity, realistic images with a social media aesthetic.
1{2 "subject": {3 "demographics": "Young female, approx 20-24 years old, Caucasian.",...+85 more lines
Ready to get started?
Free and open source.

