Compare commits
14 Commits
52444350c1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1977730d93 | |||
| 0005f3e41a | |||
| a44133aff2 | |||
| 421d004faf | |||
| 558f6e5601 | |||
| 80e87cdc1f | |||
| c8f072661f | |||
| 2656e713ea | |||
| a9a0ca350d | |||
| 014b28be77 | |||
| d2600f1279 | |||
| 472b3e10d9 | |||
|
|
1d89a4918e | ||
|
|
688b87e98d |
72
AGENT-BOOTSTRAP.md
Normal file
72
AGENT-BOOTSTRAP.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Agent Bootstrap
|
||||
|
||||
Read this file first when `bible/` is attached as a submodule.
|
||||
|
||||
Do not read the whole repository by default. This repository is a rule library, not a codebase.
|
||||
Use targeted reading.
|
||||
|
||||
## Reading Order
|
||||
|
||||
1. Read this file.
|
||||
2. Read `bible-local/README.md`.
|
||||
3. Read only the relevant files in `bible-local/architecture/` and `bible-local/decisions/`.
|
||||
4. Read only the relevant shared contracts in `bible/rules/patterns/`.
|
||||
|
||||
## Editing This Repository
|
||||
|
||||
If you are editing this `bible/` repository itself, read the target contract and its nearby
|
||||
`README.md`. Do not walk all contracts unless the task is explicitly about restructuring the library.
|
||||
|
||||
- Edit the relevant existing contract, or create a new `rules/patterns/<topic>/contract.md`.
|
||||
- Normative rules go in `contract.md`; examples and rationale go in the pattern's `README.md`.
|
||||
- Do not create rules outside `rules/patterns/`. Do not expand scope beyond engineering rules and patterns.
|
||||
- Run `sh scripts/lint.sh` after changes.
|
||||
|
||||
## Always-On Contracts
|
||||
|
||||
Read these on most tasks:
|
||||
|
||||
- `bible/rules/patterns/kiss/contract.md`
|
||||
- `bible/rules/patterns/module-structure/contract.md`
|
||||
- `bible/rules/patterns/task-discipline/contract.md`
|
||||
- `bible/rules/patterns/testing-policy/contract.md`
|
||||
- `bible/rules/patterns/go-code-style/contract.md`
|
||||
- `bible/rules/patterns/git-sync-check/contract.md`
|
||||
|
||||
## Task Router
|
||||
|
||||
Read additional contracts by task type:
|
||||
|
||||
- HTTP handlers, JSON APIs, status codes:
|
||||
`go-api`, `go-background-tasks`, `go-logging`
|
||||
- DB queries, migrations, backups, startup DB safety:
|
||||
`go-database`, `backup-management`
|
||||
- Local-first desktop migration/recovery:
|
||||
`local-first-recovery`
|
||||
- Tables, bulk actions, filters, pagination:
|
||||
`table-management`, `controls-selection`
|
||||
- Visual style for server-rendered web apps:
|
||||
`web-visual-baseline`, `table-management`, `controls-selection`
|
||||
- Forms, validation, modals:
|
||||
`forms-validation`, `modal-workflows`
|
||||
- Import/export, CSV, upload batching:
|
||||
`import-export`, `batch-file-upload`
|
||||
- Build/deploy/runtime packaging:
|
||||
`app-binary`, `build-version-display`, `module-versioning`
|
||||
- LiveCD/OpenRC/boot-time services:
|
||||
`alpine-livecd`, `unattended-boot-services`, `vendor-installer-verification`
|
||||
- Release authenticity / signed binaries:
|
||||
`release-signing`
|
||||
- Identifiers, vendors, data normalization:
|
||||
`identifier-normalization`, `no-hardcoded-vendors`
|
||||
- BOM rows, component/LOT mappings:
|
||||
`bom-decomposition`
|
||||
- Secrets, credentials, config with sensitive values:
|
||||
`secret-management`
|
||||
- Project architecture documentation rules:
|
||||
`go-project-bible`
|
||||
|
||||
## Default Rule
|
||||
|
||||
If a contract is not clearly relevant to the current task, skip it.
|
||||
Prefer reading one correct contract fully over skimming twenty unrelated ones.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Bible — Instructions for Codex
|
||||
|
||||
This repository is the shared engineering rules library for all projects.
|
||||
|
||||
Rules live in `rules/patterns/` as `contract.md` files. When adding or updating a rule:
|
||||
- Find the relevant existing contract and edit it, or create a new `rules/patterns/<topic>/contract.md`.
|
||||
- Do not create rules outside `rules/patterns/`.
|
||||
- Do not expand scope beyond engineering rules and patterns.
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -1,8 +1,5 @@
|
||||
# Bible — Instructions for Claude
|
||||
# Bible — Agent Instructions
|
||||
|
||||
This repository is the shared engineering rules library for all projects.
|
||||
|
||||
Rules live in `rules/patterns/` as `contract.md` files. When adding or updating a rule:
|
||||
- Find the relevant existing contract and edit it, or create a new `rules/patterns/<topic>/contract.md`.
|
||||
- Do not create rules outside `rules/patterns/`.
|
||||
- Do not expand scope beyond engineering rules and patterns.
|
||||
Shared engineering rules library. Start with `AGENT-BOOTSTRAP.md`; it defines the read
|
||||
path, the always-on contracts, the task router, and the rules for editing this repository.
|
||||
Do not read the whole repository by default.
|
||||
|
||||
25
README.md
25
README.md
@@ -4,6 +4,9 @@ Shared engineering rules library for Go web projects.
|
||||
|
||||
Add as a git submodule to any project — agents (Claude, Codex) will read the rules automatically.
|
||||
|
||||
Agents start at `AGENT-BOOTSTRAP.md` — it defines the read path, always-on contracts,
|
||||
and the task router. Nothing in this README duplicates it.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
@@ -17,21 +20,10 @@ git submodule update --remote bible
|
||||
## Structure
|
||||
|
||||
```
|
||||
rules/patterns/ — shared engineering rule contracts
|
||||
go-logging/ — slog, server-side only
|
||||
go-database/ — cursor safety, soft delete, GORM, N+1
|
||||
go-api/ — REST conventions, error format, status codes
|
||||
go-background-tasks/ — Task Manager pattern, polling
|
||||
go-code-style/ — layering, error wrapping, startup sequence
|
||||
go-project-bible/ — how to write and maintain a project bible
|
||||
bom-decomposition/ — one BOM row to many component/LOT mappings
|
||||
import-export/ — CSV Excel-compatible format, streaming export
|
||||
table-management/ — toolbar, filtering, pagination
|
||||
modal-workflows/ — state machine, htmx pattern, confirmation
|
||||
forms-validation/ — validation, multi-step flows
|
||||
controls-selection/ — buttons, checkboxes, segmented filters
|
||||
rules/ai/claude/
|
||||
CLAUDE.template.md — base CLAUDE.md template for new projects
|
||||
AGENT-BOOTSTRAP.md — first file agents should read; contains the full contract router
|
||||
rules/patterns/<topic>/ — one contract.md per topic (plus optional README.md with examples)
|
||||
rules/ai/AGENT.template.md — template for new projects; copy as both CLAUDE.md and AGENTS.md
|
||||
scripts/lint.sh — consistency checks (router coverage, broken links)
|
||||
```
|
||||
|
||||
## Project Setup
|
||||
@@ -39,7 +31,6 @@ rules/ai/claude/
|
||||
Each project needs:
|
||||
- `bible/` — this submodule
|
||||
- `bible-local/` — project-specific architecture (data model, API, ADL)
|
||||
- `CLAUDE.md` + `AGENTS.md` — point agents to both
|
||||
- `CLAUDE.md` + `AGENTS.md` — copies of `rules/ai/AGENT.template.md` pointing agents to the bootstrap
|
||||
|
||||
See `rules/ai/claude/CLAUDE.template.md` for a ready-made template.
|
||||
See `rules/patterns/go-project-bible/contract.md` for what goes in `bible-local/`.
|
||||
|
||||
17
rules/ai/AGENT.template.md
Normal file
17
rules/ai/AGENT.template.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# {{ .project_name }} — Agent Instructions
|
||||
|
||||
Copy this file into a project as both `CLAUDE.md` and `AGENTS.md` (identical content).
|
||||
|
||||
## Shared Engineering Rules
|
||||
Read `bible/AGENT-BOOTSTRAP.md` first and follow its routing.
|
||||
Do not read the whole `bible/` submodule; the bootstrap defines the read path and the
|
||||
always-on contracts.
|
||||
|
||||
## Project Architecture
|
||||
Read `bible-local/README.md` first, then only the relevant files in
|
||||
`bible-local/architecture/` and `bible-local/decisions/`.
|
||||
Every architectural decision specific to this project must be recorded in `bible-local/`.
|
||||
|
||||
## Default Rule
|
||||
Do not claim you "read bible" unless you actually read the relevant files.
|
||||
Do not walk all shared contracts unless the task is explicitly about changing the rules library itself.
|
||||
@@ -1,55 +0,0 @@
|
||||
# {{ .project_name }} — Instructions for Claude
|
||||
|
||||
## Shared Engineering Rules
|
||||
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
|
||||
Start with `bible/rules/patterns/` for specific contracts.
|
||||
|
||||
## Project Architecture
|
||||
Read `bible-local/` — project-specific architecture.
|
||||
Every architectural decision specific to this project must be recorded in `bible-local/`.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference (full contracts in `bible/rules/patterns/`)
|
||||
|
||||
### Go Code Style (`go-code-style/contract.md`)
|
||||
- Handler → Service → Repository. No SQL in handlers, no HTTP writes in services.
|
||||
- Errors: `fmt.Errorf("context: %w", err)`. Never discard with `_`.
|
||||
- `gofmt` before every commit.
|
||||
- Thresholds and status logic on the server — UI only reflects what server returns.
|
||||
|
||||
### Logging (`go-logging/contract.md`)
|
||||
- `slog`, stdout/stderr only. Never `console.log` as substitute for server logging.
|
||||
- Always log: startup, task start/finish/error, export row counts, ingest results, any 500.
|
||||
|
||||
### Database (`go-database/contract.md`)
|
||||
- **CRITICAL**: never run SQL on the same tx while iterating a cursor. Two-phase: read all → close → write.
|
||||
- Soft delete via `is_active = false`.
|
||||
- Fail-fast DB ping before starting HTTP server.
|
||||
- No N+1: use JOINs or batch `WHERE id IN (...)`.
|
||||
- GORM: `gorm:"-"` = fully ignored; `gorm:"-:migration"` = skip migration only.
|
||||
|
||||
### REST API (`go-api/contract.md`)
|
||||
- Plural nouns: `/api/assets`, `/api/components`.
|
||||
- Never `200 OK` for errors — use `422` for validation, `404`, `500`.
|
||||
- Error body: `{"error": "message", "fields": {"field": "reason"}}`.
|
||||
- List response always includes `total_count`, `page`, `per_page`, `total_pages`.
|
||||
- `/health` and `/api/db-status` required in every app.
|
||||
|
||||
### Background Tasks (`go-background-tasks/contract.md`)
|
||||
- Slow ops (>300ms): POST → `{task_id}` → client polls `/api/tasks/:id`.
|
||||
- No SSE. Polling only. Return `202 Accepted`.
|
||||
|
||||
### Tables, Filtering, Pagination (`table-management/contract.md`)
|
||||
- Server-side only. Filter state in URL params. Filter resets to page 1.
|
||||
- Display: "51–100 из 342".
|
||||
|
||||
### Modals (`modal-workflows/contract.md`)
|
||||
- States: open → submitting → success | error.
|
||||
- Destructive actions require confirmation modal naming the target.
|
||||
- Never close on error. Use `422` for validation errors in htmx flows.
|
||||
|
||||
### CSV Export (`import-export/contract.md`)
|
||||
- BOM: `\xEF\xBB\xBF`. Delimiter: `;`. Decimal: `,` (`1 234,56`). Dates: `DD.MM.YYYY`.
|
||||
- Stream via callback — never load all rows into memory.
|
||||
- Always call `w.Flush()` after the loop.
|
||||
90
rules/patterns/alpine-livecd/README.md
Normal file
90
rules/patterns/alpine-livecd/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Alpine LiveCD Pattern Notes
|
||||
|
||||
This file keeps examples and rationale. The normative rules live in `contract.md`.
|
||||
|
||||
## Minimal mkimage Profile
|
||||
|
||||
```sh
|
||||
profile_<name>() {
|
||||
arch="x86_64"
|
||||
hostname="<hostname>"
|
||||
apkovl="genapkovl-<name>.sh"
|
||||
image_ext="iso"
|
||||
output_format="iso"
|
||||
kernel_flavors="lts"
|
||||
initfs_cmdline="modules=loop,squashfs,sd-mod,usb-storage quiet"
|
||||
initfs_features="ata base cdrom ext4 mmc nvme raid scsi squashfs usb virtio"
|
||||
grub_mod="all_video disk part_gpt part_msdos linux normal configfile search search_label efi_gop fat iso9660 cat echo ls test true help gzio"
|
||||
apks="alpine-base linux-lts linux-firmware-none ..."
|
||||
}
|
||||
```
|
||||
|
||||
`arch` is the easiest field to miss. Without it, mkimage may silently skip the profile.
|
||||
|
||||
## apkovl Placement
|
||||
|
||||
`genapkovl-<name>.sh` must be in the current working directory when mkimage runs.
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
cp "genapkovl-<name>.sh" ~/.mkimage/
|
||||
cp "genapkovl-<name>.sh" /var/tmp/
|
||||
cd /var/tmp
|
||||
sh mkimage.sh --workdir /var/tmp/work ...
|
||||
```
|
||||
|
||||
## `/var/tmp` Build Root
|
||||
|
||||
Use `/var/tmp` instead of `/tmp`:
|
||||
|
||||
```sh
|
||||
export TMPDIR=/var/tmp
|
||||
cd /var/tmp
|
||||
sh mkimage.sh ...
|
||||
```
|
||||
|
||||
On Alpine builders, `/tmp` is often a small tmpfs and firmware/modloop builds overflow it.
|
||||
|
||||
## Cache Reuse
|
||||
|
||||
Typical cache-preserving cleanup:
|
||||
|
||||
```sh
|
||||
if [ -d /var/tmp/bee-iso-work ]; then
|
||||
find /var/tmp/bee-iso-work -maxdepth 1 -mindepth 1 \
|
||||
-not -name 'apks_*' \
|
||||
-not -name 'kernel_*' \
|
||||
-not -name 'syslinux_*' \
|
||||
-not -name 'grub_*' \
|
||||
-exec rm -rf {} +
|
||||
fi
|
||||
```
|
||||
|
||||
The apkovl section should still be rebuilt every time.
|
||||
|
||||
## Faster Squashfs
|
||||
|
||||
```sh
|
||||
mkdir -p /etc/mkinitfs
|
||||
grep -q 'MKSQUASHFS_OPTS' /etc/mkinitfs/mkinitfs.conf 2>/dev/null || \
|
||||
echo 'MKSQUASHFS_OPTS="-comp lz4 -Xhc"' >> /etc/mkinitfs/mkinitfs.conf
|
||||
```
|
||||
|
||||
## Long-Running Builds
|
||||
|
||||
```sh
|
||||
apk add screen
|
||||
screen -dmS build sh -c "sh build.sh > /var/log/build.log 2>&1"
|
||||
tail -f /var/log/build.log
|
||||
```
|
||||
|
||||
## Firmware Reminder
|
||||
|
||||
Typical extra firmware packages:
|
||||
|
||||
- `linux-firmware-intel`
|
||||
- `linux-firmware-mellanox`
|
||||
- `linux-firmware-bnx2x`
|
||||
- `linux-firmware-rtl_nic`
|
||||
- `linux-firmware-other`
|
||||
@@ -7,136 +7,17 @@ Version: 1.0
|
||||
Rules for building bootable Alpine Linux ISO images with custom overlays using `mkimage.sh`.
|
||||
Applies to any project that needs a LiveCD: hardware audit, rescue environments, kiosks.
|
||||
|
||||
---
|
||||
See `README.md` for detailed examples and build snippets.
|
||||
|
||||
## mkimage Profile
|
||||
## Rules
|
||||
|
||||
Every project must have a profile file `mkimg.<name>.sh` defining:
|
||||
|
||||
```sh
|
||||
profile_<name>() {
|
||||
arch="x86_64" # REQUIRED — without this mkimage silently skips the profile
|
||||
hostname="<hostname>"
|
||||
apkovl="genapkovl-<name>.sh"
|
||||
image_ext="iso"
|
||||
output_format="iso"
|
||||
kernel_flavors="lts"
|
||||
initfs_cmdline="modules=loop,squashfs,sd-mod,usb-storage quiet"
|
||||
initfs_features="ata base cdrom ext4 mmc nvme raid scsi squashfs usb virtio"
|
||||
grub_mod="all_video disk part_gpt part_msdos linux normal configfile search search_label efi_gop fat iso9660 cat echo ls test true help gzio"
|
||||
apks="alpine-base linux-lts linux-firmware-none ..."
|
||||
}
|
||||
```
|
||||
|
||||
**`arch` is mandatory.** If missing, mkimage silently builds nothing and exits 0.
|
||||
|
||||
---
|
||||
|
||||
## apkovl Mechanism
|
||||
|
||||
The apkovl is a `.tar.gz` overlay extracted by initramfs at boot, overlaying `/etc`, `/usr`, `/root`.
|
||||
|
||||
`genapkovl-<name>.sh` generates the tarball:
|
||||
- Must be in the **CWD** when mkimage runs — not only in `~/.mkimage/`
|
||||
- `~/.mkimage/` is searched for mkimg profiles only, not genapkovl scripts
|
||||
|
||||
```sh
|
||||
# Copy both scripts to ~/.mkimage AND to CWD (typically /var/tmp)
|
||||
cp "genapkovl-<name>.sh" ~/.mkimage/
|
||||
cp "genapkovl-<name>.sh" /var/tmp/
|
||||
cd /var/tmp
|
||||
sh mkimage.sh --workdir /var/tmp/work ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Environment
|
||||
|
||||
**Always use `/var/tmp`, not `/tmp`:**
|
||||
|
||||
```sh
|
||||
export TMPDIR=/var/tmp
|
||||
cd /var/tmp
|
||||
sh mkimage.sh ...
|
||||
```
|
||||
|
||||
`/tmp` on Alpine builder VMs is typically a 1GB tmpfs. Kernel firmware squashfs alone exceeds this.
|
||||
`/var/tmp` uses actual disk space.
|
||||
|
||||
---
|
||||
|
||||
## Workdir Caching
|
||||
|
||||
mkimage stores each ISO section in a hash-named subdirectory. Preserve expensive sections across builds:
|
||||
|
||||
```sh
|
||||
# Delete everything EXCEPT cached sections
|
||||
if [ -d /var/tmp/bee-iso-work ]; then
|
||||
find /var/tmp/bee-iso-work -maxdepth 1 -mindepth 1 \
|
||||
-not -name 'apks_*' \ # downloaded packages
|
||||
-not -name 'kernel_*' \ # modloop squashfs
|
||||
-not -name 'syslinux_*' \ # syslinux bootloader
|
||||
-not -name 'grub_*' \ # grub EFI
|
||||
-exec rm -rf {} +
|
||||
fi
|
||||
```
|
||||
|
||||
The apkovl section is always regenerated (contains project-specific config that changes per build).
|
||||
|
||||
---
|
||||
|
||||
## Squashfs Compression
|
||||
|
||||
Default compression is `xz` — slow but small. For RAM-loaded modloops, size rarely matters.
|
||||
Use `lz4` for faster builds:
|
||||
|
||||
```sh
|
||||
mkdir -p /etc/mkinitfs
|
||||
grep -q 'MKSQUASHFS_OPTS' /etc/mkinitfs/mkinitfs.conf 2>/dev/null || \
|
||||
echo 'MKSQUASHFS_OPTS="-comp lz4 -Xhc"' >> /etc/mkinitfs/mkinitfs.conf
|
||||
```
|
||||
|
||||
Apply before running mkimage. Rebuilds modloop only when kernel version changes.
|
||||
|
||||
---
|
||||
|
||||
## Long Builds
|
||||
|
||||
NVIDIA driver downloads, kernel compiles, and package fetches can take 10–30 minutes.
|
||||
Run in a `screen` session so builds survive SSH disconnects:
|
||||
|
||||
```sh
|
||||
apk add screen
|
||||
screen -dmS build sh -c "sh build.sh > /var/log/build.log 2>&1"
|
||||
tail -f /var/log/build.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NIC Firmware
|
||||
|
||||
`linux-firmware-none` (default) contains zero firmware files. Real hardware NICs often require firmware.
|
||||
Include firmware packages matching expected hardware:
|
||||
|
||||
```
|
||||
linux-firmware-intel # Intel NICs (X710, E810, etc.)
|
||||
linux-firmware-mellanox # Mellanox/NVIDIA ConnectX
|
||||
linux-firmware-bnx2x # Broadcom NetXtreme
|
||||
linux-firmware-rtl_nic # Realtek
|
||||
linux-firmware-other # catch-all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
Pin all versions in a single `VERSIONS` file sourced by all build scripts:
|
||||
|
||||
```sh
|
||||
ALPINE_VERSION=3.21
|
||||
KERNEL_VERSION=6.12
|
||||
GO_VERSION=1.23.6
|
||||
NVIDIA_DRIVER_VERSION=590.48.01
|
||||
```
|
||||
|
||||
Never hardcode versions inside build scripts.
|
||||
- Every project must have `mkimg.<name>.sh`.
|
||||
- `arch` is mandatory in the mkimage profile. If it is missing, `mkimage.sh` may exit 0 without building anything.
|
||||
- The `apkovl` generator `genapkovl-<name>.sh` must be present in the current working directory when `mkimage.sh` runs.
|
||||
- `~/.mkimage/` is for mkimg profiles only. Do not assume mkimage will find `genapkovl` there.
|
||||
- Run builds in `/var/tmp`, not `/tmp`. LiveCD builds often exceed typical `/tmp` tmpfs size.
|
||||
- Preserve expensive mkimage cache sections between builds when possible. Regenerate the apkovl section every build.
|
||||
- For RAM-loaded modloops, prefer faster squashfs settings such as `lz4` unless the project explicitly optimizes for smallest ISO size.
|
||||
- Long builds must run in a resilient session (`screen`, `tmux`, or equivalent) so SSH disconnects do not kill the build.
|
||||
- `linux-firmware-none` alone is not sufficient for real hardware targets. Include firmware packages matching the expected NIC/storage hardware.
|
||||
- Pin all build-critical versions in one shared versions file sourced by the build scripts. Do not hardcode versions inline in multiple scripts.
|
||||
|
||||
78
rules/patterns/app-binary/README.md
Normal file
78
rules/patterns/app-binary/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Application Binary Pattern Notes
|
||||
|
||||
This file keeps examples and rollout snippets. The normative rules live in `contract.md`.
|
||||
|
||||
## Host Layout
|
||||
|
||||
Default application root:
|
||||
|
||||
```text
|
||||
/appdata/<appname>/
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
scp bin/myservice user@host:/appdata/myservice/myservice
|
||||
scp docker-compose.yml user@host:/appdata/myservice/docker-compose.yml
|
||||
ssh user@host "mkdir -p /appdata/myservice"
|
||||
ssh user@host "cd /appdata/myservice && docker compose up -d"
|
||||
```
|
||||
|
||||
## Embedded Resources
|
||||
|
||||
Typical embedded assets:
|
||||
|
||||
- HTML templates
|
||||
- static JS/CSS/icons
|
||||
- `config.template.yaml`
|
||||
- DB migrations
|
||||
|
||||
## Config Template Example
|
||||
|
||||
```yaml
|
||||
# <appname> configuration
|
||||
# Generated on first run. Edit as needed.
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: ""
|
||||
password: ""
|
||||
dbname: ""
|
||||
```
|
||||
|
||||
## First-Run Behavior
|
||||
|
||||
```text
|
||||
Start
|
||||
-> config missing
|
||||
-> create directory
|
||||
-> write template
|
||||
-> print config path
|
||||
-> exit 0
|
||||
```
|
||||
|
||||
Expected message:
|
||||
|
||||
```text
|
||||
Config created: ~/.config/<appname>/config.yaml
|
||||
Edit the file and restart the application.
|
||||
```
|
||||
|
||||
## Build Examples
|
||||
|
||||
Without CGO:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/<appname> ./cmd/<appname>
|
||||
```
|
||||
|
||||
With CGO where required:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o bin/<appname> ./cmd/<appname>
|
||||
```
|
||||
@@ -6,152 +6,19 @@ Version: 1.0
|
||||
|
||||
Правила сборки, упаковки ресурсов и первого запуска Go-приложений.
|
||||
|
||||
---
|
||||
See `README.md` for deployment examples and a sample config template.
|
||||
|
||||
## Расположение на хосте
|
||||
## Rules
|
||||
|
||||
> Это правило применяется когда ИИ самостоятельно разворачивает приложение или выполняет команды на build-машине (деплой, копирование файлов, запуск сервисов).
|
||||
|
||||
Бинарник приложения размещается в директории:
|
||||
|
||||
```
|
||||
/appdata/<appname>/
|
||||
```
|
||||
|
||||
где `<appname>` — имя приложения (строчными буквами, без пробелов).
|
||||
|
||||
Пример: приложение `myservice` → `/appdata/myservice/myservice`.
|
||||
|
||||
Все файлы, связанные с конкретным приложением (бинарник, вспомогательные скрипты запуска, `docker-compose.yml`), хранятся внутри этой директории. Конфиг и данные — по правилам секций ниже.
|
||||
|
||||
### Примеры внедрения
|
||||
|
||||
При деплое, копировании файлов или запуске сервисов ИИ **всегда по умолчанию** использует этот путь:
|
||||
|
||||
```bash
|
||||
# Копирование бинарника
|
||||
scp bin/myservice user@host:/appdata/myservice/myservice
|
||||
|
||||
# Копирование docker-compose
|
||||
scp docker-compose.yml user@host:/appdata/myservice/docker-compose.yml
|
||||
|
||||
# Запуск на хосте
|
||||
ssh user@host "cd /appdata/myservice && docker compose up -d"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Создание директории если не существует
|
||||
ssh user@host "mkdir -p /appdata/myservice"
|
||||
```
|
||||
|
||||
Не предлагать альтернативные пути (`/opt/`, `/usr/local/bin/`, `~/`) — только `/appdata/<appname>/`.
|
||||
|
||||
---
|
||||
|
||||
## Бинарник
|
||||
|
||||
Бинарник самодостаточен — все ресурсы встроены через `//go:embed`:
|
||||
|
||||
- HTML-шаблоны
|
||||
- Статика (JS, CSS, иконки)
|
||||
- Шаблон конфиг-файла (`config.template.yaml`)
|
||||
- Миграции БД
|
||||
|
||||
Никаких внешних папок рядом с бинарником не требуется для запуска.
|
||||
|
||||
---
|
||||
|
||||
## Конфиг-файл
|
||||
|
||||
Создаётся автоматически при первом запуске, если не существует.
|
||||
|
||||
### Расположение
|
||||
|
||||
| Режим приложения | Путь |
|
||||
|---|---|
|
||||
| Однопользовательское | `~/.config/<appname>/config.yaml` |
|
||||
| Серверное / многопользовательское | `/etc/<appname>/config.yaml` или рядом с бинарником |
|
||||
|
||||
Приложение само определяет путь и создаёт директорию если её нет.
|
||||
|
||||
### Содержимое
|
||||
|
||||
Конфиг хранит:
|
||||
|
||||
- Настройки приложения (порт, язык, таймауты, feature flags)
|
||||
- Параметры подключения к централизованной СУБД (host, port, user, password, dbname)
|
||||
|
||||
Конфиг **не хранит**:
|
||||
|
||||
- Данные пользователя
|
||||
- Кеш или состояние
|
||||
- Что-либо что относится к SQLite (см. ниже)
|
||||
|
||||
### Шаблон
|
||||
|
||||
Шаблон конфига встроен в бинарник. При создании файла шаблон копируется в целевой путь.
|
||||
Шаблон содержит все ключи с комментариями и дефолтными значениями.
|
||||
|
||||
```yaml
|
||||
# <appname> configuration
|
||||
# Generated on first run. Edit as needed.
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
database:
|
||||
host: localhost
|
||||
port: 5432
|
||||
user: ""
|
||||
password: ""
|
||||
dbname: ""
|
||||
|
||||
# ... остальные настройки
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SQLite (однопользовательский режим)
|
||||
|
||||
Если приложение использует локальную SQLite:
|
||||
|
||||
- Файл хранится рядом с конфигом: `~/.config/<appname>/<appname>.db`
|
||||
- Путь к файлу не выносится в конфиг — приложение вычисляет его из пути конфига
|
||||
- SQLite **не хранит** параметры подключения к централизованной СУБД — только локальные данные приложения
|
||||
|
||||
---
|
||||
|
||||
## Первый запуск — алгоритм
|
||||
|
||||
```
|
||||
Старт приложения
|
||||
│
|
||||
├── Конфиг существует? → Нет → создать директорию → скопировать шаблон → сообщить пользователю путь
|
||||
│ → завершить с кодом 0
|
||||
│ (пользователь заполняет конфиг)
|
||||
└── Конфиг существует? → Да → валидировать → запустить приложение
|
||||
```
|
||||
|
||||
При первом создании конфига приложение **не запускается** — выводит сообщение:
|
||||
|
||||
```
|
||||
Config created: ~/.config/<appname>/config.yaml
|
||||
Edit the file and restart the application.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Сборка
|
||||
|
||||
Финальный бинарник собирается без CGO если это возможно (для SQLite — с CGO):
|
||||
|
||||
```
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/<appname> ./cmd/<appname>
|
||||
```
|
||||
|
||||
С SQLite:
|
||||
```
|
||||
CGO_ENABLED=1 go build -ldflags="-s -w" -o bin/<appname> ./cmd/<appname>
|
||||
```
|
||||
|
||||
Бинарник не зависит от рабочей директории запуска.
|
||||
- When the agent deploys or runs commands on a host, the application lives in `/appdata/<appname>/`.
|
||||
- Do not suggest alternate default install paths such as `/opt`, `/usr/local/bin`, or `~/`.
|
||||
- The binary must be self-contained. Templates, static assets, config templates, and DB migrations are embedded with `//go:embed` or an equivalent application-owned mechanism.
|
||||
- The application creates its config automatically on first run if it does not exist yet.
|
||||
- Default config path:
|
||||
- single-user mode: `~/.config/<appname>/config.yaml`
|
||||
- server or multi-user mode: `/etc/<appname>/config.yaml` or next to the binary
|
||||
- Config stores application settings and centralized DB credentials only. It must not store user data, cache/state, or SQLite path configuration.
|
||||
- For local SQLite mode, the database file lives next to the config and its path is derived by the application, not configured separately.
|
||||
- On first run with no config, the application must create the config, print its path, exit 0, and stop. It must not continue startup with a fresh placeholder config.
|
||||
- The binary must not depend on the caller's working directory.
|
||||
- Build with `CGO_ENABLED=0` when possible. Enable CGO only when the chosen storage/runtime actually requires it, such as SQLite drivers that need CGO.
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
# Contract: Batch File Upload
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
## Purpose
|
||||
|
||||
ADR: стратегия загрузки большого числа файлов через multipart-запросы
|
||||
без переработки серверного pipeline и без скрытых лимитов.
|
||||
Загрузка большого числа файлов одним multipart-запросом упирается в скрытые лимиты:
|
||||
количество parts, размер тела запроса (413), таймауты соединения. Поэтому клиент делит
|
||||
список файлов на батчи фиксированного размера и отправляет каждый батч отдельным
|
||||
multipart-запросом. Цена решения — больше round-trips и, без агрегации, несколько
|
||||
выходных файлов.
|
||||
|
||||
---
|
||||
|
||||
## ADR
|
||||
|
||||
**Дата:** 2026-03-01
|
||||
**Статус:** Accepted
|
||||
|
||||
### Контекст
|
||||
|
||||
Клиент должен загрузить список файлов на сервер для обработки.
|
||||
Загрузка всех файлов одним multipart-запросом упирается в скрытые лимиты:
|
||||
количество parts, размер тела запроса (413), таймауты соединения.
|
||||
Переработка серверного pipeline под стриминговую загрузку — отдельная дорогостоящая задача.
|
||||
|
||||
### Решение
|
||||
|
||||
Клиент делит список файлов на батчи фиксированного размера и отправляет каждый батч
|
||||
отдельным multipart-запросом.
|
||||
## Решение
|
||||
|
||||
- Размер батча определяется константой `MAX_FILES_PER_BATCH` (выбирается проектом).
|
||||
- Батчи считаются по **числу файлов**, не только по байтам.
|
||||
@@ -33,18 +19,6 @@ ADR: стратегия загрузки большого числа файло
|
||||
- Каждый батч производит отдельный downloadable артефакт,
|
||||
либо агрегируется на финальном шаге — решение принимается на уровне проекта.
|
||||
|
||||
### Последствия
|
||||
|
||||
**Плюсы:**
|
||||
- Избегаем скрытых лимитов на количество multipart parts
|
||||
- Снижаем риск таймаутов и ошибок 413
|
||||
- Не требует немедленной переработки серверного parser pipeline
|
||||
|
||||
**Минусы:**
|
||||
- Больше round-trips (N батчей = N запросов)
|
||||
- Несколько выходных файлов если артефакты не агрегируются
|
||||
- Более долгий end-to-end UX для пользователя
|
||||
|
||||
---
|
||||
|
||||
## Правила реализации
|
||||
|
||||
98
rules/patterns/bom-decomposition/README.md
Normal file
98
rules/patterns/bom-decomposition/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# BOM Decomposition Pattern Notes
|
||||
|
||||
This file keeps examples and reference types. The normative rules live in `contract.md`.
|
||||
|
||||
## Canonical JSON Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"sort_order": 10,
|
||||
"item_code": "SYS-821GE-TNHR",
|
||||
"quantity": 3,
|
||||
"description": "Vendor bundle",
|
||||
"unit_price": 12000.00,
|
||||
"total_price": 36000.00,
|
||||
"component_mappings": [
|
||||
{ "component_ref": "CHASSIS_X13_8GPU", "quantity_per_item": 1 },
|
||||
{ "component_ref": "PS_3000W_Titanium", "quantity_per_item": 2 },
|
||||
{ "component_ref": "RAILKIT_X13", "quantity_per_item": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Project-specific aliases are acceptable if the semantics stay identical:
|
||||
|
||||
- `item_code` -> `vendor_partnumber`
|
||||
- `component_ref` -> `lot_name`
|
||||
- `component_mappings` -> `lot_mappings`
|
||||
- `quantity_per_item` -> `quantity_per_pn`
|
||||
|
||||
## Wrong Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor_spec": [
|
||||
{
|
||||
"sort_order": 10,
|
||||
"vendor_partnumber": "ABC-123",
|
||||
"primary_lot": "LOT_CPU",
|
||||
"secondary_lots": ["LOT_RAIL"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Go Types
|
||||
|
||||
```go
|
||||
type BOMItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
ItemCode string `json:"item_code"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ComponentMappings []ComponentMapping `json:"component_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type ComponentMapping struct {
|
||||
ComponentRef string `json:"component_ref"`
|
||||
QuantityPerItem int `json:"quantity_per_item"`
|
||||
}
|
||||
```
|
||||
|
||||
## Normalization Sketch
|
||||
|
||||
```go
|
||||
func NormalizeComponentMappings(in []ComponentMapping) ([]ComponentMapping, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
merged := map[string]int{}
|
||||
order := make([]string, 0, len(in))
|
||||
|
||||
for _, m := range in {
|
||||
ref := strings.TrimSpace(m.ComponentRef)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
if m.QuantityPerItem <= 0 {
|
||||
return nil, fmt.Errorf("component %q has invalid quantity_per_item %d", ref, m.QuantityPerItem)
|
||||
}
|
||||
if _, exists := merged[ref]; !exists {
|
||||
order = append(order, ref)
|
||||
}
|
||||
merged[ref] += m.QuantityPerItem
|
||||
}
|
||||
|
||||
out := make([]ComponentMapping, 0, len(order))
|
||||
for _, ref := range order {
|
||||
out = append(out, ComponentMapping{
|
||||
ComponentRef: ref,
|
||||
QuantityPerItem: merged[ref],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
@@ -15,288 +15,43 @@ Use this contract when:
|
||||
- one bundle SKU expands into multiple internal components
|
||||
- one external line item contributes quantities to multiple downstream rows
|
||||
|
||||
## Canonical Data Model
|
||||
See `README.md` for full JSON and Go examples.
|
||||
|
||||
One BOM row has one item quantity and zero or more mapping entries:
|
||||
## Canonical Shape
|
||||
|
||||
```json
|
||||
{
|
||||
"sort_order": 10,
|
||||
"item_code": "SYS-821GE-TNHR",
|
||||
"quantity": 3,
|
||||
"description": "Vendor bundle",
|
||||
"unit_price": 12000.00,
|
||||
"total_price": 36000.00,
|
||||
"component_mappings": [
|
||||
{ "component_ref": "CHASSIS_X13_8GPU", "quantity_per_item": 1 },
|
||||
{ "component_ref": "PS_3000W_Titanium", "quantity_per_item": 2 },
|
||||
{ "component_ref": "RAILKIT_X13", "quantity_per_item": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- A BOM row contains one quantity plus zero or more mapping entries in one array field.
|
||||
- `component_mappings[]` is the only canonical persisted decomposition format.
|
||||
- Each mapping entry contains:
|
||||
- `component_ref` — stable identifier of the downstream component/LOT
|
||||
- `quantity_per_item` — how many units of that component are produced by one BOM row unit
|
||||
- Derived or UI-only fields may exist at runtime, but they are not the source of truth.
|
||||
- Each mapping entry has:
|
||||
- `component_ref`
|
||||
- `quantity_per_item`
|
||||
- Project-specific field names are allowed only if the semantics stay identical.
|
||||
|
||||
Project-specific names are allowed if the semantics stay identical:
|
||||
- `item_code` may be `vendor_partnumber`
|
||||
- `component_ref` may be `lot_name`, `lot_code`, or another stable project identifier
|
||||
- `component_mappings` may be `lot_mappings`
|
||||
## Quantity and Persistence Rules
|
||||
|
||||
## Quantity Semantics
|
||||
- Downstream quantity is always `row.quantity * mapping.quantity_per_item`.
|
||||
- The persisted row payload is the source of truth.
|
||||
- The same mapping shape must be used for persistence, API read/write payloads, and downstream expansion logic.
|
||||
- If the mapping array is empty, the row contributes nothing downstream.
|
||||
- Row order is defined by `sort_order`.
|
||||
- Mapping entry order may be preserved for UX, but business logic must not depend on it.
|
||||
|
||||
The total downstream quantity is always:
|
||||
## UI and Validation Rules
|
||||
|
||||
```text
|
||||
downstream_total_qty = row.quantity * mapping.quantity_per_item
|
||||
```
|
||||
|
||||
Example:
|
||||
- BOM row quantity = `3`
|
||||
- mapping A quantity per item = `1`
|
||||
- mapping B quantity per item = `2`
|
||||
|
||||
Result:
|
||||
- component A total = `3`
|
||||
- component B total = `6`
|
||||
|
||||
This multiplication rule is mandatory for estimate/cart/build expansion.
|
||||
|
||||
## Persistence Contract
|
||||
|
||||
The source of truth is the persisted BOM row JSON payload.
|
||||
|
||||
If the project stores BOM rows:
|
||||
- in a SQL JSON column, the JSON payload is the source of truth
|
||||
- in a text column containing JSON, that JSON payload is the source of truth
|
||||
- in an API document later persisted as JSON, the row payload shape must remain unchanged
|
||||
|
||||
Example persisted payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor_spec": [
|
||||
{
|
||||
"sort_order": 10,
|
||||
"vendor_partnumber": "ABC-123",
|
||||
"quantity": 2,
|
||||
"description": "Bundle",
|
||||
"lot_mappings": [
|
||||
{ "lot_name": "LOT_CPU", "quantity_per_pn": 1 },
|
||||
{ "lot_name": "LOT_RAIL", "quantity_per_pn": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Persistence rules:
|
||||
- the decomposition must be stored inside each BOM row
|
||||
- all mapping entries for that row must live in one array field
|
||||
- no secondary storage format may act as a competing source of truth
|
||||
|
||||
## API Contract
|
||||
|
||||
API read and write payloads must expose the same decomposition shape that is persisted.
|
||||
|
||||
Rules:
|
||||
- `GET` returns BOM rows with `component_mappings[]` or the project-specific equivalent
|
||||
- `PUT` / `POST` accepts the same shape
|
||||
- rebuild/apply/cart expansion must read only from the persisted mapping array
|
||||
- if the mapping array is empty, the row contributes nothing downstream
|
||||
- row order is defined by `sort_order`
|
||||
- mapping entry order may be preserved for UX, but business logic must not depend on it
|
||||
|
||||
Correct:
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor_spec": [
|
||||
{
|
||||
"sort_order": 10,
|
||||
"vendor_partnumber": "ABC-123",
|
||||
"quantity": 2,
|
||||
"lot_mappings": [
|
||||
{ "lot_name": "LOT_CPU", "quantity_per_pn": 1 },
|
||||
{ "lot_name": "LOT_RAIL", "quantity_per_pn": 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Wrong:
|
||||
|
||||
```json
|
||||
{
|
||||
"vendor_spec": [
|
||||
{
|
||||
"sort_order": 10,
|
||||
"vendor_partnumber": "ABC-123",
|
||||
"primary_lot": "LOT_CPU",
|
||||
"secondary_lots": ["LOT_RAIL"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## UI Invariants
|
||||
|
||||
The UI may render the mapping list in any layout, but it must preserve the same semantics.
|
||||
|
||||
Rules:
|
||||
- the first visible mapping row is not special; it is only the first entry in the array
|
||||
- additional rows may be added via `+`, modal, inline insert, or another UI affordance
|
||||
- every mapping row is equally editable and removable
|
||||
- `quantity_per_item` is edited per mapping row, not once for the whole row
|
||||
- blank mapping rows may exist temporarily in draft UI state, but they must not be persisted
|
||||
- new UI rows should default `quantity_per_item` to `1`
|
||||
|
||||
## Normalization and Validation
|
||||
|
||||
Two stages are allowed:
|
||||
- draft UI normalization for convenience
|
||||
- server-side persistence validation for correctness
|
||||
|
||||
Canonical rules before persistence:
|
||||
- trim `component_ref`
|
||||
- drop rows with empty `component_ref`
|
||||
- reject `quantity_per_item <= 0` with a validation error
|
||||
- merge duplicate `component_ref` values within one BOM row by summing `quantity_per_item`
|
||||
- preserve first-seen order when merging duplicates
|
||||
|
||||
Example input:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "component_ref": "LOT_A", "quantity_per_item": 1 },
|
||||
{ "component_ref": " LOT_A ", "quantity_per_item": 2 },
|
||||
{ "component_ref": "", "quantity_per_item": 5 }
|
||||
]
|
||||
```
|
||||
|
||||
Normalized result:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "component_ref": "LOT_A", "quantity_per_item": 3 }
|
||||
]
|
||||
```
|
||||
|
||||
Why validation instead of silent repair:
|
||||
- API contracts between applications must fail loudly on invalid quantities
|
||||
- UI may prefill `1`, but the server must not silently reinterpret `0` or negative values
|
||||
- The first mapping row is not special. Every mapping row is equally editable and removable.
|
||||
- `quantity_per_item` is edited per mapping row, not once for the whole BOM row.
|
||||
- Blank mapping rows may exist temporarily in draft UI state, but they must not be persisted.
|
||||
- New UI rows should default `quantity_per_item` to `1`.
|
||||
- Before persistence:
|
||||
- trim `component_ref`
|
||||
- drop empty `component_ref` rows
|
||||
- reject `quantity_per_item <= 0`
|
||||
- merge duplicate `component_ref` values by summing quantities
|
||||
- preserve first-seen order when merging duplicates
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
Do not introduce incompatible storage or logic variants such as:
|
||||
- `primary_lot`, `secondary_lots`, `main_component`, `bundle_lots`
|
||||
- one field for the component and a separate field for its quantity outside the mapping array
|
||||
- special-case logic where the first mapping row is "main" and later rows are optional add-ons
|
||||
- computing downstream rows from temporary UI fields instead of the persisted mapping array
|
||||
- storing the same decomposition in multiple shapes at once
|
||||
|
||||
## Reference Go Types
|
||||
|
||||
```go
|
||||
type BOMItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
ItemCode string `json:"item_code"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
ComponentMappings []ComponentMapping `json:"component_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type ComponentMapping struct {
|
||||
ComponentRef string `json:"component_ref"`
|
||||
QuantityPerItem int `json:"quantity_per_item"`
|
||||
}
|
||||
```
|
||||
|
||||
Project-specific aliases are acceptable if they preserve identical semantics:
|
||||
|
||||
```go
|
||||
type VendorSpecItem struct {
|
||||
SortOrder int `json:"sort_order"`
|
||||
VendorPartnumber string `json:"vendor_partnumber"`
|
||||
Quantity int `json:"quantity"`
|
||||
Description string `json:"description,omitempty"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||
}
|
||||
|
||||
type VendorSpecLotMapping struct {
|
||||
LotName string `json:"lot_name"`
|
||||
QuantityPerPN int `json:"quantity_per_pn"`
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Normalization (Go)
|
||||
|
||||
```go
|
||||
func NormalizeComponentMappings(in []ComponentMapping) ([]ComponentMapping, error) {
|
||||
if len(in) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
merged := map[string]int{}
|
||||
order := make([]string, 0, len(in))
|
||||
|
||||
for _, m := range in {
|
||||
ref := strings.TrimSpace(m.ComponentRef)
|
||||
if ref == "" {
|
||||
continue
|
||||
}
|
||||
if m.QuantityPerItem <= 0 {
|
||||
return nil, fmt.Errorf("component %q has invalid quantity_per_item %d", ref, m.QuantityPerItem)
|
||||
}
|
||||
if _, exists := merged[ref]; !exists {
|
||||
order = append(order, ref)
|
||||
}
|
||||
merged[ref] += m.QuantityPerItem
|
||||
}
|
||||
|
||||
out := make([]ComponentMapping, 0, len(order))
|
||||
for _, ref := range order {
|
||||
out = append(out, ComponentMapping{
|
||||
ComponentRef: ref,
|
||||
QuantityPerItem: merged[ref],
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Expansion (Go)
|
||||
|
||||
```go
|
||||
type CartItem struct {
|
||||
ComponentRef string
|
||||
Quantity int
|
||||
}
|
||||
|
||||
func ExpandBOMRow(row BOMItem) []CartItem {
|
||||
result := make([]CartItem, 0, len(row.ComponentMappings))
|
||||
for _, m := range row.ComponentMappings {
|
||||
qty := row.Quantity * m.QuantityPerItem
|
||||
if qty <= 0 {
|
||||
continue
|
||||
}
|
||||
result = append(result, CartItem{
|
||||
ComponentRef: m.ComponentRef,
|
||||
Quantity: qty,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
- Do not introduce alternate persisted shapes such as `primary_lot`, `secondary_lots`, `main_component`, or `bundle_lots`.
|
||||
- Do not split the component and its quantity across unrelated fields outside the mapping array.
|
||||
- Do not treat the first mapping row as a special primary component.
|
||||
- Do not compute downstream decomposition from temporary UI-only fields instead of the persisted mapping array.
|
||||
- Do not store the same decomposition in multiple competing formats.
|
||||
|
||||
29
rules/patterns/build-version-display/README.md
Normal file
29
rules/patterns/build-version-display/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Build Version Display Pattern Notes
|
||||
|
||||
This file keeps examples. The normative rules live in `contract.md`.
|
||||
|
||||
## Frontend (JS/TS build tools)
|
||||
|
||||
```ts
|
||||
// vite.config.ts / webpack.config.js
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.APP_VERSION ?? "dev"),
|
||||
}
|
||||
|
||||
// Footer component
|
||||
<footer>v{__APP_VERSION__}</footer>
|
||||
```
|
||||
|
||||
## Go (server-rendered HTML)
|
||||
|
||||
```go
|
||||
// main.go
|
||||
var Version = "dev"
|
||||
|
||||
// Build: go build -ldflags "-X main.Version=1.4.2"
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- base template -->
|
||||
<footer>v{{ .Version }}</footer>
|
||||
```
|
||||
@@ -1,18 +1,13 @@
|
||||
# Contract: Build Version Display
|
||||
|
||||
Version: 1.0
|
||||
|
||||
## Purpose
|
||||
|
||||
Every web application must display the current build version in the page footer so that users and support staff can identify exactly which version is running.
|
||||
|
||||
---
|
||||
Version: 1.1
|
||||
|
||||
## Rule
|
||||
|
||||
The build version **must** be visible in the footer on every page of the web application.
|
||||
The build version **must** be visible in the footer on every page of the web application,
|
||||
so users and support staff can identify exactly which version is running.
|
||||
|
||||
---
|
||||
See `README.md` for implementation snippets.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -22,42 +17,6 @@ The build version **must** be visible in the footer on every page of the web app
|
||||
- Format: any human-readable string that uniquely identifies the build — a semver tag, a git commit SHA, or a combination (e.g. `1.4.2`, `1.4.2-abc1234`, `abc1234`).
|
||||
- The version text must be legible but visually subordinate — use a muted color and small font size so it does not compete with page content.
|
||||
|
||||
---
|
||||
|
||||
## Recommended implementation
|
||||
|
||||
**Frontend (JS/TS build tools)**
|
||||
|
||||
Expose the version through an environment variable at build time and reference it in the footer component:
|
||||
|
||||
```ts
|
||||
// vite.config.ts / webpack.config.js
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.APP_VERSION ?? "dev"),
|
||||
}
|
||||
|
||||
// Footer component
|
||||
<footer>v{__APP_VERSION__}</footer>
|
||||
```
|
||||
|
||||
**Go (server-rendered HTML)**
|
||||
|
||||
Inject via `-ldflags` at build time and pass to the template:
|
||||
|
||||
```go
|
||||
// main.go
|
||||
var Version = "dev"
|
||||
|
||||
// Build: go build -ldflags "-X main.Version=1.4.2"
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- base template -->
|
||||
<footer>v{{ .Version }}</footer>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What is NOT allowed
|
||||
|
||||
- Omitting the version from any page, including error pages.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Controls + Selection Pattern
|
||||
|
||||
Canonical interactive controls for server-rendered admin/list pages:
|
||||
|
||||
- button hierarchy (primary / secondary / danger / quiet / disabled)
|
||||
- segmented filters
|
||||
- row checkboxes + select-all-visible semantics
|
||||
- bulk-action bar with explicit preview/confirm steps
|
||||
- status badges for list rows and workflow state
|
||||
|
||||
This pattern standardizes control language and interaction shape while leaving branding and
|
||||
domain terminology to the host project.
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
# Contract: Controls + Selection
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
## Shared Base
|
||||
|
||||
- This pattern inherits the shared `table-management` contract:
|
||||
`kit/patterns/table-management/contract.md`.
|
||||
- This pattern inherits the shared `table-management` contract.
|
||||
- Visual styling comes from `web-visual-baseline`.
|
||||
- Reuse shared toolbar/table/icon geometry from the base contract first, then define only
|
||||
controls-specific additions below.
|
||||
|
||||
## Buttons
|
||||
|
||||
- Use a small stable button taxonomy: `primary`, `secondary`, `ghost`, `danger`, `disabled`.
|
||||
- The canonical visual baseline for demo/scaffold examples is the active dual baseline used in the
|
||||
demo/scaffold (`Vapor Soft` / `Vapor Night`, system-selected). A frozen Aqua snapshot bundle may
|
||||
be kept for archival reference, but examples should follow the active baseline.
|
||||
- Destructive actions (`archive`, `delete`, `remove`) must use danger styling and explicit labels.
|
||||
- Button text should describe the action outcome, not implementation detail.
|
||||
- Buttons are text-first; icons are optional and must not replace labels on primary/danger actions.
|
||||
@@ -26,7 +23,6 @@ Version: 1.0
|
||||
- Header checkbox semantics must be explicit:
|
||||
- select visible rows only, or
|
||||
- select all rows in query scope (must be clearly labeled)
|
||||
- Selection summary must show count (`N selected`) near bulk actions.
|
||||
- In paginated views, the UI should distinguish selection on the current page from selection across the filtered/query scope.
|
||||
- Selection state should survive pagination/filter navigation via explicit state (query params, server session, or another deterministic mechanism).
|
||||
- Same-page interactions should preserve reading position (module anchor pattern is preferred in canonical server-rendered flows).
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Forms + Validation Pattern
|
||||
|
||||
Canonical patterns for server-rendered form workflows in Go web applications:
|
||||
|
||||
- tabbed / mode-switched forms
|
||||
- datalist/autocomplete suggestions
|
||||
- inline validation messages
|
||||
- review / confirm step before submit
|
||||
- explicit reset and error handling states
|
||||
|
||||
This pattern standardizes interaction flow and validation UX, not domain-specific field sets.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Contract: Forms + Validation + Suggestions
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
This contract owns validation UX and multi-step flow rules for all form surfaces,
|
||||
including modals (`modal-workflows` adds only modal-specific mechanics).
|
||||
|
||||
## Form Structure
|
||||
|
||||
@@ -10,8 +13,10 @@ Version: 1.0
|
||||
|
||||
## Validation
|
||||
|
||||
- Validate on submit server-side. Client-side validation is optional progressive enhancement only.
|
||||
- Surface validation errors inline near fields and in a form-level summary when helpful.
|
||||
- Validation messages must be human-readable and action-oriented.
|
||||
- Validation messages must be human-readable and action-oriented: "Serial number is required" —
|
||||
not "serial_number: cannot be null".
|
||||
- Do not hide required-field errors behind generic submit failures.
|
||||
|
||||
## Multi-Step Flow
|
||||
@@ -26,6 +31,7 @@ Recommended stages:
|
||||
Rules:
|
||||
|
||||
- Users must be able to return from review to edit without losing entered values.
|
||||
- The confirm step must summarise what will happen before a destructive or irreversible action.
|
||||
- Destructive or irreversible actions require explicit confirmation.
|
||||
- Query- or state-driven step transitions should be deterministic and testable.
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
## Rule
|
||||
|
||||
Before starting any work on a task, check whether the remote repository has commits that are not yet present locally.
|
||||
Before starting any work on a task (repositories are hosted in Gitea):
|
||||
|
||||
## Required Steps
|
||||
1. `git fetch origin`
|
||||
2. `git log HEAD..@{u} --oneline`
|
||||
3. Non-empty output: **stop immediately, make no changes**, tell the user the remote has
|
||||
new commits and ask how to proceed (pull, rebase, ignore).
|
||||
4. Empty output: proceed normally.
|
||||
|
||||
1. Run `git fetch` to update remote-tracking refs without merging.
|
||||
2. Check for upstream commits: `git log HEAD..@{u} --oneline`.
|
||||
3. If the output is non-empty (there are new remote commits):
|
||||
- **Stop immediately. Do not make any changes.**
|
||||
- Inform the user that the remote has new commits and ask how to proceed (e.g., pull, rebase, or ignore).
|
||||
4. If the output is empty, proceed with the task normally.
|
||||
If offline and fetch is impossible: notify the user before proceeding.
|
||||
Web UI inspection does not replace the local check.
|
||||
|
||||
## Rationale
|
||||
## Gitea Workflow
|
||||
|
||||
Working on an outdated local state risks merge conflicts, duplicate work, and overwriting changes made by other contributors. Checking remote state first keeps the working tree aligned and prevents avoidable conflicts.
|
||||
|
||||
## Exceptions
|
||||
|
||||
- Offline environments where `git fetch` is not possible: notify the user that the check could not be performed before proceeding.
|
||||
- Create a task branch before changes (`git checkout -b <branch>`), push with
|
||||
`git push -u origin <branch>`, review via a Gitea **pull request** (the `tea` CLI may be
|
||||
used if configured).
|
||||
- Do not assume GitHub tooling such as `gh`; do not use GitLab terminology such as
|
||||
`merge request`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contract: REST API Conventions (Go Web Applications)
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
## URL Naming
|
||||
|
||||
@@ -82,12 +82,5 @@ GET /api/db-status → 200 {"ok": true} or 500 {"ok": false, "error": "..."}
|
||||
|
||||
## Async Actions
|
||||
|
||||
For long-running operations return immediately with a task reference:
|
||||
|
||||
```
|
||||
POST /api/pricelists/create → 202 {"task_id": "abc123"}
|
||||
GET /api/tasks/abc123 → 200 {"status": "running", "progress": 42, "message": "Processing..."}
|
||||
GET /api/tasks/abc123 → 200 {"status": "success", "result": {...}}
|
||||
```
|
||||
|
||||
See `go-background-tasks/contract.md` for full task contract.
|
||||
Long-running operations return `202 {"task_id": "..."}` immediately and the client polls
|
||||
`GET /api/tasks/:id`. Full flow, statuses, and task struct: `go-background-tasks` contract.
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
# Contract: Go Code Style and Project Conventions
|
||||
|
||||
Version: 1.1
|
||||
Version: 1.3
|
||||
|
||||
## Source Text and Comments
|
||||
|
||||
- Use plain ASCII in source code and comments by default.
|
||||
- Do not use em dash, emoji, or other decorative Unicode markers in code, comments, log messages, or user-facing fallback strings unless a feature explicitly requires non-ASCII text.
|
||||
- Do not leave AI-style markers in the codebase: ornamental phrasing, assistant-like filler, synthetic enthusiasm, or comments that read like generated prose instead of technical documentation.
|
||||
- Comments must be short, concrete, and technical. Explain intent, invariants, or non-obvious constraints; do not write marketing-style or conversational commentary.
|
||||
|
||||
## Logging
|
||||
|
||||
See `kit/patterns/go-logging/contract.md` for full rules.
|
||||
See the `go-logging` contract for full rules.
|
||||
|
||||
Summary: use `slog`, log to stdout/stderr (binary console), never to browser console.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Always wrap errors with context. Use `fmt.Errorf("...: %w", err)`.
|
||||
|
||||
```go
|
||||
// CORRECT
|
||||
if err := db.Save(&record).Error; err != nil {
|
||||
return fmt.Errorf("save component %s: %w", record.ID, err)
|
||||
}
|
||||
|
||||
// WRONG — loses context
|
||||
return err
|
||||
```
|
||||
|
||||
- Always wrap errors with context: `fmt.Errorf("save component %s: %w", record.ID, err)`.
|
||||
A bare `return err` loses context.
|
||||
- Never silently discard errors with `_` in production paths.
|
||||
- Return errors up the call stack; log at the handler/task boundary, not deep in service code.
|
||||
|
||||
@@ -35,9 +32,9 @@ return err
|
||||
Handlers are thin. Business logic belongs in a service layer.
|
||||
|
||||
```
|
||||
Handler → validates input, calls service, writes response
|
||||
Service → business logic, calls repository
|
||||
Repository → SQL queries only, returns domain types
|
||||
Handler -> validates input, calls service, writes response
|
||||
Service -> business logic, calls repository
|
||||
Repository -> SQL queries only, returns domain types
|
||||
```
|
||||
|
||||
- Handlers must not contain SQL queries.
|
||||
@@ -48,7 +45,7 @@ Repository → SQL queries only, returns domain types
|
||||
|
||||
```
|
||||
1. Parse flags / load config
|
||||
2. Connect to DB — fail fast if unavailable (see go-database contract)
|
||||
2. Connect to DB; fail fast if unavailable (see go-database contract)
|
||||
3. Run migrations
|
||||
4. Initialize services and background workers
|
||||
5. Register routes
|
||||
@@ -69,14 +66,14 @@ Never reverse steps 2 and 5. Never start serving before migrations complete.
|
||||
## Template / UI Rendering
|
||||
|
||||
- Server-rendered HTML via Go templates is the default.
|
||||
- htmx for partial updates — no full SPA framework unless explicitly decided.
|
||||
- htmx for partial updates; no full SPA framework unless explicitly decided.
|
||||
- Template errors must return `500` and log the error server-side.
|
||||
- Never expose raw Go error messages to the end user in rendered HTML.
|
||||
|
||||
## Business Logic Placement
|
||||
|
||||
- Threshold computation, status derivation, and scoring live on the server.
|
||||
- The UI only reflects what the server returns — it does not recompute status client-side.
|
||||
- The UI only reflects what the server returns; it does not recompute status client-side.
|
||||
- Example: "critical / warning / ok" badge color is determined by the handler, not by JS.
|
||||
|
||||
## Dependency Rules
|
||||
|
||||
72
rules/patterns/go-database/README.md
Normal file
72
rules/patterns/go-database/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Database Pattern Notes
|
||||
|
||||
This file keeps examples and rationale. The normative rules live in `contract.md`.
|
||||
|
||||
## Cursor Safety
|
||||
|
||||
Wrong:
|
||||
|
||||
```go
|
||||
rows, _ := tx.Query("SELECT id FROM machines")
|
||||
for rows.Next() {
|
||||
var id string
|
||||
rows.Scan(&id)
|
||||
tx.Exec("UPDATE machines SET processed=1 WHERE id=?", id)
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```go
|
||||
rows, _ := tx.Query("SELECT id FROM machines")
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
rows.Scan(&id)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, id := range ids {
|
||||
tx.Exec("UPDATE machines SET processed=1 WHERE id=?", id)
|
||||
}
|
||||
```
|
||||
|
||||
## GORM Virtual Fields
|
||||
|
||||
```go
|
||||
Count int `gorm:"-"`
|
||||
DisplayName string `gorm:"-:migration"`
|
||||
```
|
||||
|
||||
## SQL Header Example
|
||||
|
||||
```sql
|
||||
-- Tables affected: supplier, lot_log
|
||||
-- recovery.not-started: No action required.
|
||||
-- recovery.partial: DELETE FROM parts_log WHERE created_by = 'migration';
|
||||
-- recovery.completed: Same as partial.
|
||||
-- verify: No orphaned supplier_code | SELECT supplier_code FROM parts_log pl LEFT JOIN supplier s ON s.supplier_code = pl.supplier_code WHERE s.supplier_code IS NULL AND pl.supplier_code IS NOT NULL AND pl.supplier_code != '' LIMIT 1
|
||||
```
|
||||
|
||||
## Docker Validation Example
|
||||
|
||||
```bash
|
||||
docker run -d --name pf_test \
|
||||
-e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=RFQ_LOG \
|
||||
mariadb:11.8 --character-set-server=utf8mb4 --collation-server=utf8mb4_uca1400_ai_ci
|
||||
|
||||
docker exec -i pf_test mariadb -uroot -ptest RFQ_LOG < prod_dump.sql
|
||||
|
||||
./pfs -migrate-dsn "root:test@tcp(127.0.0.1:3306)/RFQ_LOG?parseTime=true&charset=utf8mb4&multiStatements=true" \
|
||||
-no-backup -verbose
|
||||
```
|
||||
|
||||
## Legacy FK Repair Pattern
|
||||
|
||||
```sql
|
||||
INSERT IGNORE INTO parent (name)
|
||||
SELECT DISTINCT c.fk_col FROM child c
|
||||
LEFT JOIN parent p ON p.name = c.fk_col
|
||||
WHERE p.name IS NULL AND c.fk_col IS NOT NULL AND c.fk_col != '';
|
||||
```
|
||||
@@ -1,148 +1,58 @@
|
||||
# Contract: Database Patterns (Go / MySQL / MariaDB)
|
||||
|
||||
Version: 1.8
|
||||
Version: 1.10
|
||||
|
||||
## MySQL Transaction Cursor Safety (CRITICAL)
|
||||
See `README.md` for examples, migration snippets, and Docker test commands.
|
||||
|
||||
**Never execute SQL on the same transaction while iterating over a query result cursor.**
|
||||
## Query and Startup Rules
|
||||
|
||||
This is the most common source of `invalid connection` and `unexpected EOF` driver panics.
|
||||
|
||||
### Rule
|
||||
|
||||
Use a two-phase approach: read all rows first, close the cursor, then execute writes.
|
||||
|
||||
```go
|
||||
// WRONG — executes SQL inside rows.Next() loop on the same tx
|
||||
rows, _ := tx.Query("SELECT id FROM machines")
|
||||
for rows.Next() {
|
||||
var id string
|
||||
rows.Scan(&id)
|
||||
tx.Exec("UPDATE machines SET processed=1 WHERE id=?", id) // DEADLOCK / driver panic
|
||||
}
|
||||
|
||||
// CORRECT — collect IDs first, then write
|
||||
rows, _ := tx.Query("SELECT id FROM machines")
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
rows.Scan(&id)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close() // explicit close before any write
|
||||
|
||||
for _, id := range ids {
|
||||
tx.Exec("UPDATE machines SET processed=1 WHERE id=?", id)
|
||||
}
|
||||
```
|
||||
|
||||
This applies to:
|
||||
- `database/sql` with manual transactions
|
||||
- GORM `db.Raw().Scan()` inside a `db.Transaction()` callback
|
||||
- Any loop that calls a repository method while a cursor is open
|
||||
|
||||
## Soft Delete / Archive Pattern
|
||||
|
||||
Do not use hard deletes for user-visible records. Use an archive flag.
|
||||
|
||||
```go
|
||||
// Schema: is_active bool DEFAULT true
|
||||
// "Delete" = set is_active = false
|
||||
// Restore = set is_active = true
|
||||
|
||||
// All list queries must filter:
|
||||
WHERE is_active = true
|
||||
```
|
||||
|
||||
- Never physically delete rows that have foreign key references or history.
|
||||
- Hard delete is only acceptable for orphaned/temporary data with no audit trail requirement.
|
||||
- Never execute SQL on the same transaction while iterating an open result cursor. Use a two-phase flow: read all rows, close the cursor, then execute writes.
|
||||
- This rule applies to `database/sql`, GORM transactions, and any repository call made while another cursor in the same transaction is still open.
|
||||
- Violation symptoms: `[mysql] invalid connection`, `unexpected EOF`, `driver: bad connection` in Go logs; `Got an error reading communication packets` in MariaDB/MySQL error log. These are driver-level failures, not application errors — the root cause is always a nested SQL call on an open cursor.
|
||||
- Recompute/rebuild/repair flows are the most common violation sites: audit them explicitly.
|
||||
- User-visible records use soft delete or archive flags. Do not hard-delete records with history or foreign-key references.
|
||||
- Archive operations must be reversible from the UI.
|
||||
- Use `gorm:"-"` only for fields that must be ignored entirely. Use `gorm:"-:migration"` for fields populated by queries but excluded from migrations.
|
||||
- Always verify the DB connection before starting the HTTP server. Never serve traffic with an unverified DB connection.
|
||||
- Prevent N+1 queries. Do not query inside loops over rows from another query; use JOINs or batched `IN (...)` queries.
|
||||
|
||||
## GORM Virtual Fields
|
||||
## Migration and Backup Rules
|
||||
|
||||
Use the correct tag based on whether the field should exist in the DB schema:
|
||||
- The migration engine owns backup creation. The operator must never be required to take a manual pre-migration backup.
|
||||
- Backup storage, retention, archive format, and restore-readiness must follow `backup-management`.
|
||||
- Before applying any unapplied migrations, take and verify a full DB backup.
|
||||
- Before applying a migration step that changes a table, take a targeted backup of each affected table.
|
||||
- If any migration step in a session fails, roll back all steps applied in that session in reverse order.
|
||||
- If rollback is not sufficient, restore from the targeted backup taken before the failing step.
|
||||
- After rollback or restore, the DB must be back in the same state it had before the session started.
|
||||
- Migration failures must emit structured diagnostics naming the failed step, rollback actions, and final DB state.
|
||||
|
||||
```go
|
||||
// Field computed at runtime, column must NOT exist in DB (excludes from migrations AND queries)
|
||||
Count int `gorm:"-"`
|
||||
## Migration Authoring Rules
|
||||
|
||||
// Field computed at query time via JOIN/SELECT, column must NOT be in migrations
|
||||
// but IS populated from query results
|
||||
DisplayName string `gorm:"-:migration"`
|
||||
```
|
||||
- For local-first desktop apps, migration recovery must also follow `local-first-recovery`.
|
||||
- Migrations are sequential and immutable after merge.
|
||||
- Each migration should be reversible where possible.
|
||||
- Do not rename a column in one step. Add new, backfill, and drop old across separate deploys.
|
||||
- Auto-apply on startup is allowed for internal tools only if the behavior is documented.
|
||||
- Every `.sql` migration file must start with:
|
||||
- `-- Tables affected: ...`
|
||||
- `-- recovery.not-started: ...`
|
||||
- `-- recovery.partial: ...`
|
||||
- `-- recovery.completed: ...`
|
||||
- one or more `-- verify: <description> | <SQL>` checks
|
||||
- Verify queries must return rows only when something is wrong.
|
||||
- Verify queries must exclude NULL and empty values when those would create false positives.
|
||||
- A migration is recorded as applied only after all verify checks pass.
|
||||
|
||||
- `gorm:"-"` — fully ignored: no migration, no read, no write.
|
||||
- `gorm:"-:migration"` — skip migration only; GORM will still read/write if the column exists.
|
||||
- Do not use `gorm:"-"` for JOIN-populated fields — the value will always be zero.
|
||||
## Pre-Production Validation Rules
|
||||
|
||||
## Fail-Fast DB Check on Startup
|
||||
- Test pending migrations on a dump of the current production DB, not on fixtures.
|
||||
- Use a local MariaDB Docker container matching the production version and collation.
|
||||
- Execute each migration file as one DB session so session variables such as `SET FOREIGN_KEY_CHECKS = 0` remain in effect for the whole file.
|
||||
- If migrations fail in Docker, fix them before touching production.
|
||||
|
||||
Always verify the database connection before starting the HTTP server.
|
||||
## Common Pitfalls
|
||||
|
||||
```go
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil || sqlDB.Ping() != nil {
|
||||
log.Fatal("database unavailable, refusing to start")
|
||||
}
|
||||
// then: run migrations, then: start gin/http server
|
||||
```
|
||||
|
||||
Never start serving traffic with an unverified DB connection. Fail loudly at boot.
|
||||
|
||||
## N+1 Query Prevention
|
||||
|
||||
Use JOINs or batch IN queries. Never query inside a loop over rows from another query.
|
||||
|
||||
```go
|
||||
// WRONG
|
||||
for _, pricelist := range pricelists {
|
||||
items, _ := repo.GetItems(pricelist.ID) // N queries
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
items, _ := repo.GetItemsByPricelistIDs(ids) // 1 query with WHERE id IN (...)
|
||||
// then group in Go
|
||||
```
|
||||
|
||||
## Automatic Backup During Migration
|
||||
|
||||
The migration engine is responsible for all backup steps. The operator must never be required to take a backup manually.
|
||||
|
||||
Backup naming, storage, archive format, retention, and restore-readiness must follow the `backup-management` contract.
|
||||
|
||||
### Full DB Backup on New Migrations
|
||||
|
||||
When the migration engine detects that new (unapplied) migrations exist, it must take a full database backup before applying any of them.
|
||||
|
||||
Rules:
|
||||
- The full backup must complete and be verified before the first migration step runs.
|
||||
- The backup must be triggered by the application's own backup mechanism; do not assume `mysql`, `mysqldump`, `pg_dump`, or any other external DB client tool is present on the operator's machine.
|
||||
- Before creating the backup, verify that the backup output path resolves outside the git worktree and is not tracked or staged in git.
|
||||
|
||||
### Per-Table Backup Before Each Table Migration
|
||||
|
||||
Before applying a migration step that affects a specific table, take a targeted backup of that table.
|
||||
|
||||
Rules:
|
||||
- A per-table backup must be created immediately before the migration step that modifies that table.
|
||||
- If a single migration step touches multiple tables, back up each affected table before the step runs.
|
||||
- Per-table backups are in addition to the full DB backup; they are not a substitute for it.
|
||||
|
||||
### Session Rollback on Failure
|
||||
|
||||
If any migration step fails during a session, the engine must roll back all migrations applied in that session.
|
||||
|
||||
Rules:
|
||||
- "Session" means all migration steps started in a single run of the migration engine.
|
||||
- On failure, roll back every step applied in the current session in reverse order before surfacing the error.
|
||||
- If rollback of a step is not possible (e.g., the operation is not reversible in MySQL without the per-table backup), restore from the per-table backup taken before that step.
|
||||
- After rollback or restore, the database must be in the same state as before the session started.
|
||||
- The engine must emit structured diagnostics that identify which step failed, which steps were rolled back, and the final database state.
|
||||
|
||||
## Migration Policy
|
||||
|
||||
- For local-first desktop applications, startup and migration recovery must follow the `local-first-recovery` contract.
|
||||
- Migrations are numbered sequentially and never modified after merge.
|
||||
- Each migration must be reversible where possible (document rollback in a comment).
|
||||
- Never rename a column in one migration step — add new, backfill, drop old across separate deploys.
|
||||
- Auto-apply migrations on startup is acceptable for internal tools; document if used.
|
||||
- Do not use tools that naively split SQL on bare `;`. String literals may contain semicolons.
|
||||
- `SET FOREIGN_KEY_CHECKS = 0` is session-scoped. If the file is split across multiple sessions, FK checks come back on.
|
||||
- When adding a new FK to legacy data, repair missing parent rows before enforcing the constraint unless data loss is explicitly acceptable.
|
||||
|
||||
@@ -16,29 +16,29 @@ Every project bible must have these files:
|
||||
|
||||
```
|
||||
bible-local/
|
||||
README.md — index: what files exist and what each covers
|
||||
README.md - index: what files exist and what each covers
|
||||
architecture/
|
||||
system-overview.md — what the product does, active scope, non-goals
|
||||
data-model.md — domain entities, DB tables, naming conventions
|
||||
api-surface.md — all HTTP endpoints with methods and response shape
|
||||
runtime-flows.md — key mutation flows, invariants, critical rules
|
||||
system-overview.md - what the product does, active scope, non-goals
|
||||
data-model.md - domain entities, DB tables, naming conventions
|
||||
api-surface.md - all HTTP endpoints with methods and response shape
|
||||
runtime-flows.md - key mutation flows, invariants, critical rules
|
||||
decisions/
|
||||
README.md — ADL format explanation
|
||||
YYYY-MM-DD-topic.md — one file per architectural decision
|
||||
README.md - ADL format explanation
|
||||
YYYY-MM-DD-topic.md - one file per architectural decision
|
||||
```
|
||||
|
||||
Optional (add when relevant):
|
||||
```
|
||||
architecture/
|
||||
ui-information-architecture.md — page structure, navigation, UI invariants
|
||||
ui-information-architecture.md - page structure, navigation, UI invariants
|
||||
docs/
|
||||
INTEGRATION_GUIDE.md — external system integration (formats, protocols)
|
||||
INTEGRATION_GUIDE.md - external system integration (formats, protocols)
|
||||
```
|
||||
|
||||
## system-overview.md Rules
|
||||
|
||||
- List what is **in scope** and what is **explicitly out of scope**.
|
||||
- Out of scope section prevents scope creep — update it when you reject a feature.
|
||||
- Out of scope section prevents scope creep; update it when you reject a feature.
|
||||
- Include tech stack and local run command.
|
||||
|
||||
## data-model.md Rules
|
||||
@@ -63,7 +63,7 @@ This is the most important file. Document flows that are **easy to break**:
|
||||
- Event/time source priority rules
|
||||
- Deduplication logic
|
||||
- Cross-entity side effects (e.g. removing a component affects asset status)
|
||||
- Anything that caused a bug or regression — add a "DO NOT reintroduce" note
|
||||
- Anything that caused a bug or regression: add a "DO NOT reintroduce" note
|
||||
|
||||
Format each flow as a numbered list of steps, not prose.
|
||||
|
||||
@@ -94,19 +94,20 @@ What this means going forward. What is now forbidden or required.
|
||||
```
|
||||
|
||||
- One decision per file, named `YYYY-MM-DD-short-topic.md`.
|
||||
- When a decision is superseded, add `superseded by` to the old file's status — do not delete it.
|
||||
- When a decision is superseded, add `superseded by` to the old file's status; do not delete it.
|
||||
- Record the decision **in the same commit** as the code that implements it.
|
||||
|
||||
## What NOT to Put in bible-local/
|
||||
|
||||
- Generic rules (CSV format, logging, pagination) → these are in `bible/rules/patterns/`
|
||||
- Work-in-progress notes, TODO lists → use issues or a separate `docs/` folder
|
||||
- Generic rules (CSV format, logging, pagination) -> these are in `bible/rules/patterns/`
|
||||
- Work-in-progress notes, TODO lists -> use issues or a separate `docs/` folder
|
||||
- Duplicate of what is already in code (don't restate what the code clearly shows)
|
||||
- Speculative future architecture — document what exists, not what might exist
|
||||
- Speculative future architecture: document what exists, not what might exist
|
||||
|
||||
## Keeping the Bible Current
|
||||
|
||||
- Update `bible-local/` in the **same commit** as the code change it describes.
|
||||
- If you change a flow, update `runtime-flows.md` in the same PR.
|
||||
- If a section becomes outdated, fix or delete it — stale docs are worse than no docs.
|
||||
- If a section becomes outdated, fix or delete it; stale docs are worse than no docs.
|
||||
- Bible files are in **English only**.
|
||||
- Do not use em dash in Bible files; prefer ASCII punctuation such as `-`, `:`, or `;` depending on the sentence.
|
||||
|
||||
293
rules/patterns/hardware-ingest-json/contract.md
Normal file
293
rules/patterns/hardware-ingest-json/contract.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Contract: Hardware Ingest JSON
|
||||
|
||||
Version: 2.10
|
||||
Source: `bee/bible-local/docs/hardware-ingest-contract.md` (canonical upstream)
|
||||
|
||||
Стандартный JSON-контракт для передачи данных об аппаратном обеспечении серверов в Reanimator.
|
||||
Используется в `bee`, `logpile`, `core` и внешних интеграторах (Redfish-коллекторы, CMDB-экспортёры).
|
||||
|
||||
> Актуальная версия: https://git.mchus.pro/reanimator/core/src/branch/main/bible-local/docs/hardware-ingest-contract.md
|
||||
|
||||
## Принципы
|
||||
|
||||
1. **Snapshot** — JSON описывает состояние сервера на момент сбора. Может включать историю изменений статуса.
|
||||
2. **Идемпотентность** — повторная отправка идентичного payload не создаёт дублей (дедупликация по хешу).
|
||||
3. **Частичность** — можно передавать только те секции, данные по которым доступны. Пустой массив и отсутствие секции эквивалентны.
|
||||
4. **Строгая схема** — endpoint использует строгий JSON-декодер; неизвестные поля приводят к `400 Bad Request`.
|
||||
5. **Event-driven** — импорт создаёт события в timeline (LOG_COLLECTED, INSTALLED, REMOVED, FIRMWARE_CHANGED и др.).
|
||||
6. **Без синтеза** — сборщик передаёт только фактически собранные значения. Запрещено придумывать `serial_number`, `component_ref`, `message`, `message_id` или иные идентификаторы, если источник их не предоставил.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST /ingest/hardware
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Ответ `202 Accepted` с `job_id`. Результат: `GET /ingest/hardware/jobs/{job_id}`.
|
||||
|
||||
## Структура верхнего уровня
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "redfish://10.10.10.103",
|
||||
"source_type": "api",
|
||||
"protocol": "redfish",
|
||||
"target_host": "10.10.10.103",
|
||||
"collected_at": "2026-02-10T15:30:00Z",
|
||||
"hardware": {
|
||||
"board": { ... },
|
||||
"firmware": [ ... ],
|
||||
"cpus": [ ... ],
|
||||
"memory": [ ... ],
|
||||
"storage": [ ... ],
|
||||
"pcie_devices": [ ... ],
|
||||
"power_supplies": [ ... ],
|
||||
"sensors": { ... },
|
||||
"event_logs": [ ... ],
|
||||
"platform_config": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Обязательно | Описание |
|
||||
|------|-----|-------------|----------|
|
||||
| `collected_at` | string RFC3339 | **да** | Время сбора данных |
|
||||
| `hardware` | object | **да** | Аппаратный снапшот |
|
||||
| `hardware.board.serial_number` | string | **да** | Серийный номер платы/сервера |
|
||||
| `target_host` | string | нет | IP или hostname |
|
||||
| `source_type` | string | нет | `api`, `logfile`, `manual` |
|
||||
| `protocol` | string | нет | `redfish`, `ipmi`, `snmp`, `ssh` |
|
||||
| `filename` | string | нет | Идентификатор источника |
|
||||
|
||||
## Общие поля статуса компонентов
|
||||
|
||||
Применяются ко всем компонентным секциям (`cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies`).
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `status` | string | `OK`, `Warning`, `Critical`, `Unknown`, `Empty` |
|
||||
| `status_checked_at` | string RFC3339 | Время последней проверки |
|
||||
| `status_changed_at` | string RFC3339 | Время последнего изменения |
|
||||
| `status_history` | array | История переходов (`status`, `changed_at` обязательны) |
|
||||
| `error_description` | string | Текст ошибки/диагностики |
|
||||
| `manufactured_year_week` | string | Дата производства `YYYY-Www`, например `2024-W07` |
|
||||
|
||||
Правила статуса:
|
||||
- Не включайте записи `status_history` без `changed_at`.
|
||||
- `status_history` сортировать по `changed_at` по возрастанию.
|
||||
- Все даты — RFC3339, рекомендуется UTC (`Z`).
|
||||
|
||||
| Статус | Поведение |
|
||||
|--------|-----------|
|
||||
| `OK` | Нормальная обработка |
|
||||
| `Warning` | Создаётся событие `COMPONENT_WARNING` |
|
||||
| `Critical` | Создаётся событие `COMPONENT_FAILED` + запись в `failure_events` |
|
||||
| `Unknown` | Компонент считается рабочим, создаётся событие `COMPONENT_UNKNOWN` |
|
||||
| `Empty` | Компонент не создаётся/не обновляется |
|
||||
|
||||
## Секции hardware
|
||||
|
||||
### board (обязательная)
|
||||
|
||||
| Поле | Тип | Обязательно | Описание |
|
||||
|------|-----|-------------|----------|
|
||||
| `serial_number` | string | **да** | Серийный номер (ключ идентификации Asset) |
|
||||
| `manufacturer` | string | нет | Производитель |
|
||||
| `product_name` | string | нет | Модель |
|
||||
| `part_number` | string | нет | Партномер |
|
||||
| `uuid` | string | нет | UUID системы |
|
||||
|
||||
Значения `"NULL"` в строковых полях трактуются как отсутствие данных.
|
||||
|
||||
### firmware
|
||||
|
||||
| Поле | Тип | Обязательно |
|
||||
|------|-----|-------------|
|
||||
| `device_name` | string | **да** |
|
||||
| `version` | string | **да** |
|
||||
|
||||
Записи с пустым `device_name` или `version` игнорируются. Изменение версии создаёт событие `FIRMWARE_CHANGED`.
|
||||
|
||||
### cpus
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `socket` | int | **обязательно**; используется для генерации serial |
|
||||
| `model` | string | Модель процессора |
|
||||
| `manufacturer` | string | |
|
||||
| `cores` / `threads` | int | |
|
||||
| `frequency_mhz` / `max_frequency_mhz` | int | |
|
||||
| `temperature_c` | float | Telemetry, °C |
|
||||
| `power_w` | float | Telemetry, Вт |
|
||||
| `throttled` | bool | Thermal/power throttling |
|
||||
| `correctable_error_count` / `uncorrectable_error_count` | int | |
|
||||
| `life_remaining_pct` / `life_used_pct` | float | Health/wear, % |
|
||||
| `serial_number` | string | Если доступен |
|
||||
| `firmware` | string | Версия микрокода (Microcode level — передавать как есть) |
|
||||
| `present` | bool | По умолчанию `true` |
|
||||
|
||||
Генерация serial при отсутствии: `{board_serial}-CPU-{socket}`
|
||||
|
||||
### memory
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `slot` | string | |
|
||||
| `present` | bool | По умолчанию `true` |
|
||||
| `serial_number` | string | Обязателен для создания записи |
|
||||
| `part_number` | string | Используется как модель |
|
||||
| `manufacturer` | string | |
|
||||
| `size_mb` | int | |
|
||||
| `type` | string | `DDR3`, `DDR4`, `DDR5` |
|
||||
| `max_speed_mhz` / `current_speed_mhz` | int | |
|
||||
| `temperature_c` | float | Telemetry |
|
||||
| `correctable_ecc_error_count` / `uncorrectable_ecc_error_count` | int | |
|
||||
| `life_remaining_pct` / `life_used_pct` / `spare_blocks_remaining_pct` | float | |
|
||||
| `performance_degraded` / `data_loss_detected` | bool | |
|
||||
|
||||
Модуль без `serial_number`, с `present=false` или `status=Empty` игнорируется.
|
||||
|
||||
### storage
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `slot` | string | BDF (`0000:18:00.0`) для PCIe-подключённых |
|
||||
| `serial_number` | string | Обязателен для создания записи |
|
||||
| `model` / `manufacturer` | string | |
|
||||
| `type` | string | `NVMe`, `SSD`, `HDD` |
|
||||
| `interface` | string | `NVMe`, `SATA`, `SAS` |
|
||||
| `size_gb` | int | |
|
||||
| `logical_block_size_bytes` | int64 | Логический размер блока, например `512` или `4096` |
|
||||
| `physical_block_size_bytes` | int64 | Физический размер блока |
|
||||
| `metadata_bytes_per_block` | int64 | Metadata/protection bytes на блок, например `0` или `8` |
|
||||
| `temperature_c` | float | Telemetry |
|
||||
| `power_on_hours` / `power_cycles` / `unsafe_shutdowns` | int64 | |
|
||||
| `media_errors` / `error_log_entries` | int64 | |
|
||||
| `written_bytes` / `read_bytes` | int64 | |
|
||||
| `life_used_pct` / `life_remaining_pct` / `available_spare_pct` | float | |
|
||||
| `reallocated_sectors` / `current_pending_sectors` / `offline_uncorrectable` | int64 | |
|
||||
| `firmware` | string | Изменение создаёт `FIRMWARE_CHANGED` |
|
||||
| `present` | bool | По умолчанию `true` |
|
||||
|
||||
Формат `512+8` не передаётся строкой — только через `logical_block_size_bytes` + `metadata_bytes_per_block`.
|
||||
Диск без `serial_number` игнорируется.
|
||||
|
||||
### pcie_devices
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `slot` | string | Канонический адрес (BDF). `bdf` — deprecated alias, нормализуется при ingest |
|
||||
| `vendor_id` / `device_id` | int | PCI ID (decimal) |
|
||||
| `numa_node` | int | NUMA/CPU affinity |
|
||||
| `device_class` | string | `MassStorageController`, `StorageController`, `NetworkController`, `EthernetController`, `FibreChannelController`, `VideoController`, `ProcessingAccelerator`, `DisplayController` (список открытый) |
|
||||
| `manufacturer` / `model` / `serial_number` / `firmware` | string | |
|
||||
| `link_width` / `max_link_width` | int | |
|
||||
| `link_speed` / `max_link_speed` | string | `Gen3`, `Gen4`, `Gen5` |
|
||||
| `mac_addresses` | string[] | MAC-адреса портов |
|
||||
| `temperature_c` / `power_w` | float | Device-level telemetry |
|
||||
| `life_remaining_pct` / `life_used_pct` | float | |
|
||||
| `ecc_corrected_total` / `ecc_uncorrected_total` | int64 | |
|
||||
| `hw_slowdown` | bool | |
|
||||
| `battery_charge_pct` / `battery_health_pct` / `battery_temperature_c` / `battery_voltage_v` | float | |
|
||||
| `battery_replace_required` | bool | |
|
||||
| `sfp_temperature_c` / `sfp_tx_power_dbm` / `sfp_rx_power_dbm` / `sfp_voltage_v` / `sfp_bias_ma` | float | Optical telemetry |
|
||||
| `present` | bool | По умолчанию `true` |
|
||||
|
||||
Генерация serial при отсутствии или `"N/A"`: `{board_serial}-PCIE-{slot}`
|
||||
|
||||
### power_supplies
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| `slot` | string | |
|
||||
| `present` | bool | По умолчанию `true` |
|
||||
| `serial_number` | string | Обязателен для создания записи |
|
||||
| `part_number` / `model` / `vendor` | string | |
|
||||
| `wattage_w` | int | |
|
||||
| `firmware` | string | |
|
||||
| `input_type` | string | Например `ACWideRange` |
|
||||
| `input_voltage` / `input_power_w` / `output_power_w` / `temperature_c` | float | Telemetry |
|
||||
| `life_remaining_pct` / `life_used_pct` | float | |
|
||||
|
||||
PSU без `serial_number` игнорируется.
|
||||
|
||||
### sensors (опционально)
|
||||
|
||||
Данные хранятся как last-known-value на уровне Asset. Идентификатор: `(sensor_type, name)`.
|
||||
Поле `location` передавать не нужно — игнорируется. Сенсоры без `name` игнорируются.
|
||||
|
||||
```json
|
||||
"sensors": {
|
||||
"fans": [{ "name": "FAN1", "rpm": 4200, "status": "OK" }],
|
||||
"power": [{ "name": "12V Rail", "voltage_v": 12.06, "status": "OK" }],
|
||||
"temperatures": [{ "name": "CPU0 Temp", "celsius": 46.0, "threshold_warning_celsius": 80.0, "threshold_critical_celsius": 95.0, "status": "OK" }],
|
||||
"other": [{ "name": "System Humidity", "value": 38.5, "unit": "%" }]
|
||||
}
|
||||
```
|
||||
|
||||
### event_logs (опционально)
|
||||
|
||||
Нормализованные операционные логи. Не попадают в history timeline. Дедуплицируются по `(asset, source, fingerprint)`.
|
||||
|
||||
| Поле | Тип | Обязательно | Описание |
|
||||
|------|-----|-------------|----------|
|
||||
| `source` | string | **да** | `host`, `bmc`, `redfish` |
|
||||
| `message` | string | **да** | Нормализованный текст события |
|
||||
| `event_time` | string RFC3339 | нет | |
|
||||
| `severity` | string | нет | `OK`, `Info`, `Warning`, `Critical`, `Unknown` |
|
||||
| `message_id` | string | нет | Код события источника |
|
||||
| `component_ref` | string | нет | Ссылка на компонент/слот |
|
||||
| `fingerprint` | string | нет | Внешний dedup-key; если нет — система вычисляет свой |
|
||||
| `is_active` | bool | нет | Событие всё ещё активно |
|
||||
| `raw_payload` | object | нет | Сырой vendor-specific payload |
|
||||
|
||||
Запрещено синтезировать `message`, `message_id`, `component_ref`, serial/device identifiers.
|
||||
|
||||
### platform_config (опционально)
|
||||
|
||||
Произвольный объект с настройками платформы (BIOS/Redfish/IPMI) как есть из источника.
|
||||
При каждом импорте хранится latest-snapshot per machine.
|
||||
|
||||
## Обработка отсутствующих serial_number
|
||||
|
||||
Интегратор не подставляет вымышленные значения, хеши или placeholder-идентификаторы.
|
||||
Разрешены только server-side fallback-правила:
|
||||
|
||||
| Тип | Поведение |
|
||||
|-----|-----------|
|
||||
| CPU | Генерируется: `{board_serial}-CPU-{socket}` |
|
||||
| PCIe | Генерируется: `{board_serial}-PCIE-{slot}` |
|
||||
| Memory | Компонент игнорируется |
|
||||
| Storage | Компонент игнорируется |
|
||||
| PSU | Компонент игнорируется |
|
||||
|
||||
Если `serial_number` не уникален внутри payload для того же `model`: первое вхождение — оригинальный serial, дубли получают `NO_SN-XXXXXXXX`.
|
||||
|
||||
## Минимальный валидный пример
|
||||
|
||||
```json
|
||||
{
|
||||
"collected_at": "2026-02-10T15:30:00Z",
|
||||
"target_host": "192.168.1.100",
|
||||
"hardware": {
|
||||
"board": {
|
||||
"serial_number": "SRV-001"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
| Версия | Дата | Изменения |
|
||||
|--------|------|-----------|
|
||||
| 2.10 | 2026-04-29 | `hardware.storage[]`: добавлены `logical_block_size_bytes`, `physical_block_size_bytes`, `metadata_bytes_per_block` |
|
||||
| 2.9 | 2026-03-19 | Добавлена секция `hardware.platform_config` |
|
||||
| 2.8 | 2026-03-15 | Поле `location` удалено из всех `sensors.*` |
|
||||
| 2.7 | 2026-03-15 | Явно запрещён синтез данных в `event_logs` |
|
||||
| 2.6 | 2026-03-15 | Добавлена секция `event_logs` |
|
||||
| 2.5 | 2026-03-15 | Добавлено `manufactured_year_week` для всех компонентов |
|
||||
| 2.4 | 2026-03-15 | Component telemetry: health/life поля для всех секций |
|
||||
| 2.0 | 2026-02-01 | `status_history`, `status_changed_at`; async job response |
|
||||
| 1.0 | 2026-01-01 | Начальная версия |
|
||||
54
rules/patterns/identifier-normalization/README.md
Normal file
54
rules/patterns/identifier-normalization/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Identifier Normalization Pattern Notes
|
||||
|
||||
This file keeps examples. The normative rules live in `contract.md`.
|
||||
|
||||
## Go — сравнение
|
||||
|
||||
```go
|
||||
import "strings"
|
||||
|
||||
func SameIdentifier(a, b string) bool {
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
```
|
||||
|
||||
## Go — дедупликация
|
||||
|
||||
```go
|
||||
func deduplicateBySerial(items []Device) []Device {
|
||||
seen := make(map[string]struct{})
|
||||
result := items[:0]
|
||||
for _, item := range items {
|
||||
key := strings.ToLower(item.SerialNumber)
|
||||
if _, exists := seen[key]; !exists {
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## SQL — поиск и уникальность
|
||||
|
||||
Поиск:
|
||||
|
||||
```sql
|
||||
SELECT * FROM devices WHERE LOWER(serial_number) = LOWER(?);
|
||||
```
|
||||
|
||||
Уникальный индекс (MySQL / MariaDB):
|
||||
|
||||
```sql
|
||||
-- Collation ci обеспечивает case-insensitive уникальность
|
||||
ALTER TABLE devices MODIFY serial_number VARCHAR(255)
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE devices ADD UNIQUE INDEX uniq_serial (serial_number);
|
||||
```
|
||||
|
||||
SQLite:
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uniq_serial ON devices (LOWER(serial_number));
|
||||
```
|
||||
@@ -1,13 +1,13 @@
|
||||
# Contract: Identifier Normalization
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
## Purpose
|
||||
|
||||
Правила хранения и сравнения идентификаторов оборудования:
|
||||
серийные номера, вендоры, версии прошивок, партномера, артикулы.
|
||||
|
||||
---
|
||||
See `README.md` for Go and SQL examples.
|
||||
|
||||
## Правило
|
||||
|
||||
@@ -17,11 +17,8 @@ Version: 1.0
|
||||
```
|
||||
Пришло: "SN-001-ABC" → хранится: "SN-001-ABC"
|
||||
Пришло: "sn-001-abc" → это тот же объект, не дубликат
|
||||
Пришло: "Sn-001-Abc" → то же самое
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Применяется к полям
|
||||
|
||||
- Серийный номер (`serial_number`, `serial`)
|
||||
@@ -30,61 +27,13 @@ Version: 1.0
|
||||
- Партномер (`part_number`, `part_no`)
|
||||
- Артикул (`article`, `sku`)
|
||||
|
||||
---
|
||||
|
||||
## Реализация
|
||||
|
||||
### Go — сравнение
|
||||
|
||||
```go
|
||||
import "strings"
|
||||
|
||||
func SameIdentifier(a, b string) bool {
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
```
|
||||
|
||||
### Go — дедупликация
|
||||
|
||||
```go
|
||||
func deduplicateBySerial(items []Device) []Device {
|
||||
seen := make(map[string]struct{})
|
||||
result := items[:0]
|
||||
for _, item := range items {
|
||||
key := strings.ToLower(item.SerialNumber)
|
||||
if _, exists := seen[key]; !exists {
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Ключ в map — всегда `strings.ToLower(value)`. Сам объект сохраняется с оригинальным значением.
|
||||
|
||||
### SQL — поиск и уникальность
|
||||
|
||||
Поиск:
|
||||
```sql
|
||||
SELECT * FROM devices WHERE LOWER(serial_number) = LOWER(?);
|
||||
```
|
||||
|
||||
Уникальный индекс (MySQL / MariaDB):
|
||||
```sql
|
||||
-- Collation ci обеспечивает case-insensitive уникальность
|
||||
ALTER TABLE devices MODIFY serial_number VARCHAR(255)
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE devices ADD UNIQUE INDEX uniq_serial (serial_number);
|
||||
```
|
||||
|
||||
SQLite:
|
||||
```sql
|
||||
CREATE UNIQUE INDEX uniq_serial ON devices (LOWER(serial_number));
|
||||
```
|
||||
|
||||
---
|
||||
- Go: сравнение только через `strings.EqualFold`, никогда `==`.
|
||||
- Go: ключ дедупликации в map — `strings.ToLower(value)`; сам объект хранит оригинал.
|
||||
- SQL-поиск: `WHERE LOWER(col) = LOWER(?)`.
|
||||
- Уникальность: MySQL/MariaDB — case-insensitive collation (`utf8mb4_unicode_ci`) +
|
||||
unique index; SQLite — `CREATE UNIQUE INDEX ... ON t (LOWER(col))`.
|
||||
|
||||
## Что не делать
|
||||
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
# Import / Export Pattern
|
||||
# Import / Export Pattern Notes
|
||||
|
||||
Canonical file transfer UX patterns for Go web applications:
|
||||
This file keeps examples. The normative rules live in `contract.md`.
|
||||
|
||||
- file import forms (CSV/JSON and similar)
|
||||
- validation preview tables before confirm
|
||||
- confirm step with human-readable summary
|
||||
- export controls (format + scope + options)
|
||||
- predictable file download behavior and filenames
|
||||
## Export Handler Sketch
|
||||
|
||||
This pattern covers UI and UX contracts. Business-specific validation and file schemas remain in
|
||||
the host project's own architecture docs.
|
||||
```go
|
||||
func ExportCSV(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", `attachment; filename="export.csv"`)
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
w := csv.NewWriter(c.Writer)
|
||||
w.Comma = ';'
|
||||
w.Write([]string{"ID", "Name", "Price"})
|
||||
|
||||
err := svc.StreamRows(ctx, filters, func(row Row) error {
|
||||
return w.Write([]string{row.ID, row.Name, formatPrice(row.Price)})
|
||||
})
|
||||
w.Flush()
|
||||
if err != nil {
|
||||
slog.Error("csv export failed mid-stream", "err", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Locale Notes
|
||||
|
||||
- BOM avoids broken UTF-8 in Excel on Windows.
|
||||
- Semicolon avoids single-column imports in RU/EU locales.
|
||||
- Decimal comma keeps numbers numeric in Excel.
|
||||
- `DD.MM.YYYY` is preferred over ISO dates for user-facing spreadsheet exports.
|
||||
|
||||
@@ -2,124 +2,38 @@
|
||||
|
||||
Version: 1.0
|
||||
|
||||
## Import Workflow
|
||||
See `README.md` for the reference export handler and locale examples.
|
||||
|
||||
Recommended stages:
|
||||
## Import Rules
|
||||
|
||||
1. `Upload`
|
||||
2. `Preview / Validate`
|
||||
3. `Confirm`
|
||||
4. `Execute`
|
||||
5. `Result summary`
|
||||
- Recommended flow: `Upload -> Preview / Validate -> Confirm -> Execute -> Result summary`.
|
||||
- Validation preview must be human-readable.
|
||||
- Warnings and errors should be visible per row and in aggregate.
|
||||
- The confirm step must communicate scope and side effects clearly.
|
||||
|
||||
Rules:
|
||||
## Export Rules
|
||||
|
||||
- Validation preview must be human-readable (table/list), not raw JSON only.
|
||||
- Warnings and errors should be shown per row and in aggregate summary.
|
||||
- Confirm step should clearly communicate scope and side effects.
|
||||
- The user must explicitly choose export scope when ambiguity exists, such as `selected`, `filtered`, or `all`.
|
||||
- Export format must be explicit.
|
||||
- Download responses must set `Content-Type` and `Content-Disposition` correctly.
|
||||
|
||||
## Export Workflow
|
||||
## CSV Rules
|
||||
|
||||
- User must explicitly choose export scope (`selected`, `filtered`, `all`) when ambiguity exists.
|
||||
- Export format should be explicit (`csv`, `json`, etc.).
|
||||
- Download response must set:
|
||||
- `Content-Type: text/csv; charset=utf-8`
|
||||
- `Content-Disposition: attachment; filename="..."`
|
||||
- For spreadsheet-facing CSV, write UTF-8 BOM as the first bytes.
|
||||
- Use semicolon `;` as the CSV delimiter.
|
||||
- Use comma as the decimal separator for user-facing numeric values.
|
||||
- Use `DD.MM.YYYY` for user-facing dates.
|
||||
- Use `encoding/csv` with `csv.Writer.Comma = ';'` so quoting and escaping stay correct.
|
||||
|
||||
## CSV Format Rules (Excel-compatible)
|
||||
## Streaming Rules
|
||||
|
||||
These rules are **mandatory** whenever CSV is exported for spreadsheet users.
|
||||
|
||||
### Encoding and BOM
|
||||
|
||||
- Write UTF-8 BOM (`\xEF\xBB\xBF`) as the very first bytes of the response.
|
||||
- Without BOM, Excel on Windows opens UTF-8 CSV as ANSI and garbles Cyrillic/special characters.
|
||||
|
||||
```go
|
||||
w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
```
|
||||
|
||||
### Delimiter
|
||||
|
||||
- Use **semicolon** (`;`) as the field delimiter, not comma.
|
||||
- Excel in Russian/European locale uses semicolon as the list separator.
|
||||
- Comma-delimited files open as a single column in these locales.
|
||||
|
||||
### Numbers
|
||||
|
||||
- Write decimal numbers with a **comma** as the decimal separator: `1 234,56` — not `1234.56`.
|
||||
- Excel in Russian locale does not recognize period as a decimal separator in numeric cells.
|
||||
- Format integers and floats explicitly; do not rely on Go's default `%v` or `strconv.FormatFloat`.
|
||||
- Use a thin non-breaking space (`\u202F`) or regular space as a thousands separator when the value
|
||||
benefits from readability (e.g. prices, quantities > 9999).
|
||||
|
||||
```go
|
||||
// correct
|
||||
fmt.Sprintf("%.2f", price) // then replace "." -> ","
|
||||
strings.ReplaceAll(fmt.Sprintf("%.2f", price), ".", ",")
|
||||
|
||||
// wrong — produces "1234.56", Excel treats it as text in RU locale
|
||||
fmt.Sprintf("%.2f", price)
|
||||
```
|
||||
|
||||
### Dates
|
||||
|
||||
- Write dates as `DD.MM.YYYY` — the format Excel in Russian locale parses as a date cell automatically.
|
||||
- Do not use ISO 8601 (`2006-01-02`) for user-facing CSV; it is not auto-recognized as a date in RU locale.
|
||||
|
||||
### Text quoting
|
||||
|
||||
- Wrap any field that contains the delimiter (`;`), a newline, or a double-quote in double quotes.
|
||||
- Escape embedded double-quotes by doubling them: `""`.
|
||||
- Use `encoding/csv` with `csv.Writer` and set `csv.Writer.Comma = ';'`; it handles quoting automatically.
|
||||
|
||||
## Streaming Export Architecture (Go)
|
||||
|
||||
For exports with potentially large row counts use a 3-layer streaming pattern.
|
||||
Never load all rows into memory before writing — stream directly to the response writer.
|
||||
|
||||
```
|
||||
Handler → sets HTTP headers + writes BOM → calls Service
|
||||
Service → delegates to Repository with a row callback
|
||||
Repository → queries in batches → calls callback per row
|
||||
Handler/Service → csv.Writer.Flush() after all rows
|
||||
```
|
||||
|
||||
```go
|
||||
// Handler
|
||||
func ExportCSV(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", `attachment; filename="export.csv"`)
|
||||
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) // BOM
|
||||
|
||||
w := csv.NewWriter(c.Writer)
|
||||
w.Comma = ';'
|
||||
w.Write([]string{"ID", "Name", "Price"}) // header row
|
||||
|
||||
err := svc.StreamRows(ctx, filters, func(row Row) error {
|
||||
return w.Write([]string{row.ID, row.Name, formatPrice(row.Price)})
|
||||
})
|
||||
w.Flush()
|
||||
if err != nil {
|
||||
// headers already sent — log only, cannot change status
|
||||
slog.Error("csv export failed mid-stream", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Repository — batch fetch with callback
|
||||
func (r *Repo) StreamRows(ctx, filters, fn func(Row) error) error {
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
// ... scan and call fn(row) for each row
|
||||
}
|
||||
```
|
||||
|
||||
- Use `JOIN` in the repository query to avoid N+1 per row.
|
||||
- Batch size is optional; streaming row-by-row is fine for most datasets.
|
||||
- Always call `w.Flush()` after the loop — `csv.Writer` buffers internally.
|
||||
- Large exports must stream rows directly to the response. Do not load the full dataset into memory first.
|
||||
- Use the canonical flow:
|
||||
`Handler -> Service -> Repository callback -> csv.Writer`
|
||||
- Repository queries should avoid N+1 by using JOINs or another batched shape.
|
||||
- Always call `csv.Writer.Flush()` after writing rows.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Import errors should map to clear user-facing messages.
|
||||
- Export errors after streaming starts must be logged server-side only — HTTP headers are already
|
||||
sent and the status code cannot be changed mid-stream.
|
||||
|
||||
- Import errors must map to clear user-facing messages.
|
||||
- Once streaming has started, export failures are logged server-side only. Do not try to change the HTTP status after headers/body bytes were already sent.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Modal Workflow Pattern
|
||||
|
||||
This pattern package captures modal-based create/edit/remove workflows for server-rendered Go
|
||||
web UIs.
|
||||
|
||||
Synthesis sources:
|
||||
|
||||
- detailed enterprise UI interaction contracts
|
||||
- operational admin workflows in Go web apps
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contract: Modal Workflows
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
## State Machine
|
||||
|
||||
@@ -29,11 +29,8 @@ closed → open → submitting → success | error
|
||||
|
||||
## Validation
|
||||
|
||||
- Validate on submit server-side. Client-side validation is optional progressive enhancement only.
|
||||
- Show field-level errors inline below each field.
|
||||
- Show a form-level error summary at the top if multiple fields fail.
|
||||
- Error messages must be human-readable and action-oriented: "Serial number is required" — not
|
||||
"serial_number: cannot be null".
|
||||
Validation UX follows the `forms-validation` contract (server-side on submit, inline field
|
||||
errors, form-level summary, human-readable messages).
|
||||
|
||||
## htmx Pattern (server-rendered modals)
|
||||
|
||||
@@ -44,15 +41,13 @@ POST /api/entity → 200 OK + HX-Trigger: "entitySaved" (success)
|
||||
```
|
||||
|
||||
- On success: server sends `HX-Trigger` header, JS listener closes modal and refreshes list.
|
||||
- On validation error: server re-renders the form partial with inline errors (422).
|
||||
- On validation error: server re-renders the form partial with inline errors (422, per `go-api`).
|
||||
- On server error: show generic error toast, log full error server-side.
|
||||
- Do not use `200 OK` for validation errors — use `422` so htmx can differentiate.
|
||||
|
||||
## Multi-Step Modals
|
||||
|
||||
Use only when the workflow genuinely requires staged input (e.g. import preview → confirm).
|
||||
Staged flows follow the `forms-validation` multi-step rules. Modal-specific:
|
||||
|
||||
- Use only when the workflow genuinely requires staged input (e.g. import preview → confirm).
|
||||
- Show a step indicator (Step 1 of 3).
|
||||
- Back button must restore previous step values.
|
||||
- Final confirm step must summarise what will happen before the destructive/irreversible action.
|
||||
- Single-step edits must NOT be split into multi-step without good reason.
|
||||
|
||||
36
rules/patterns/module-structure/contract.md
Normal file
36
rules/patterns/module-structure/contract.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Contract: Module Atomicity
|
||||
|
||||
Version: 1.0
|
||||
|
||||
## Principle
|
||||
|
||||
Each file has one clear responsibility. A file that does many unrelated things is a liability,
|
||||
not a convenience.
|
||||
|
||||
## Rules
|
||||
|
||||
- One concept per file. A file should be nameable in a single noun: `user_validator.go`,
|
||||
`session_store.go`, `invoice_pdf.go`. If the name requires "and" or "misc", the file is too broad.
|
||||
- Do not append new functionality to an existing file because it is nearby or already open.
|
||||
When a new piece of logic does not belong to the existing concept, create a new file.
|
||||
- A file that grows past ~200 lines is a signal to split, not a mandate — but treat it as a
|
||||
prompt to ask whether all the code in it shares a single responsibility.
|
||||
- Package-level `utils.go`, `helpers.go`, or `common.go` files are banned. Name the file after
|
||||
what the code actually does.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- Adding a new handler to `handlers.go` because there is already a handler there.
|
||||
- Putting unrelated functions in the same file to avoid creating a new one.
|
||||
- A single file containing models, queries, business logic, and HTTP concerns at once.
|
||||
- Files named after their location or role in the codebase rather than their concept:
|
||||
`api.go`, `misc.go`, `util.go`, `shared.go`.
|
||||
|
||||
## When to split
|
||||
|
||||
Split a file when:
|
||||
1. You cannot describe all of its functions in one short sentence.
|
||||
2. A new function you are adding does not share the domain of the existing ones.
|
||||
3. The file has more than one reason to change independently.
|
||||
|
||||
Do not wait until a file is "too big." Split at the moment the second responsibility appears.
|
||||
61
rules/patterns/module-versioning/README.md
Normal file
61
rules/patterns/module-versioning/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Module Versioning Pattern Notes
|
||||
|
||||
This file keeps examples and the decision tree. The normative rules live in `contract.md`.
|
||||
|
||||
## Version Format
|
||||
|
||||
```text
|
||||
N.M
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `1.0`
|
||||
- `2.0`
|
||||
- `2.1`
|
||||
- `2.3`
|
||||
|
||||
## Canonical Storage Options
|
||||
|
||||
Go constant:
|
||||
|
||||
```go
|
||||
const Version = "2.1"
|
||||
```
|
||||
|
||||
Document header:
|
||||
|
||||
```text
|
||||
Version: 2.1
|
||||
```
|
||||
|
||||
Config field:
|
||||
|
||||
```json
|
||||
{ "version": "2.1" }
|
||||
```
|
||||
|
||||
## Tag Format
|
||||
|
||||
```text
|
||||
<module-name>/v<N.M>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `parser/v2.0`
|
||||
- `api-client/v1.3`
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```text
|
||||
Module changed?
|
||||
-> no: version unchanged
|
||||
-> yes: behavior or interface changed?
|
||||
-> yes: N+1, reset minor to 0
|
||||
-> no: narrow bugfix only -> N+0.1
|
||||
```
|
||||
|
||||
## Commit Reminder
|
||||
|
||||
If a commit changes a module, the same commit should update the module version.
|
||||
@@ -9,114 +9,15 @@ Version: 1.0
|
||||
|
||||
Модули — это логические слои внутри одного репозитория, не отдельные пакеты.
|
||||
|
||||
---
|
||||
See `README.md` for examples and the decision tree.
|
||||
|
||||
## Формат версии
|
||||
## Rules
|
||||
|
||||
```
|
||||
N.M
|
||||
```
|
||||
|
||||
- `N` — мажорная версия (целое число, начинается с 1)
|
||||
- `M` — минорная версия (кратна 0.1, начинается с 0)
|
||||
|
||||
Примеры: `1.0`, `2.0`, `2.1`, `2.3`
|
||||
|
||||
---
|
||||
|
||||
## Правила инкремента
|
||||
|
||||
### N+1 — любая функциональная правка
|
||||
|
||||
Поднимаем мажор при **любом изменении функциональности**:
|
||||
|
||||
- добавление новой функции, метода, поля
|
||||
- изменение существующего поведения
|
||||
- удаление функциональности
|
||||
- рефакторинг, меняющий структуру модуля
|
||||
- изменение интерфейса взаимодействия с другими слоями
|
||||
|
||||
При инкременте мажора минор **сбрасывается в 0**: `2.3 → 3.0`
|
||||
|
||||
### N+0.1 — исправление бага
|
||||
|
||||
Поднимаем минор при **коротком точечном багфиксе**:
|
||||
|
||||
- исправление некорректного поведения без изменения интерфейса
|
||||
- правка крайнего случая (edge case)
|
||||
- исправление опечатки в логике
|
||||
|
||||
Функциональность при этом **не меняется**.
|
||||
|
||||
---
|
||||
|
||||
## Где хранить версию
|
||||
|
||||
Версия фиксируется в одном месте внутри модуля. Выбрать один из вариантов:
|
||||
|
||||
**Go** — константа в пакете:
|
||||
```go
|
||||
const Version = "2.1"
|
||||
```
|
||||
|
||||
**Файл** — заголовок contract.md или README модуля:
|
||||
```
|
||||
Version: 2.1
|
||||
```
|
||||
|
||||
**JSON/YAML конфиг** — поле `version`:
|
||||
```json
|
||||
{ "version": "2.1" }
|
||||
```
|
||||
|
||||
Не дублировать версию в нескольких местах одного модуля.
|
||||
|
||||
---
|
||||
|
||||
## Git-тег (опционально)
|
||||
|
||||
Если модуль выпускается как отдельная поставка, тег ставится в формате:
|
||||
|
||||
```
|
||||
<module-name>/v<N.M>
|
||||
```
|
||||
|
||||
Примеры: `parser/v2.0`, `api-client/v1.3`
|
||||
|
||||
Тег ставится только на коммит, в котором обновлена версия внутри модуля.
|
||||
Тег без обновления версии в коде — ошибка.
|
||||
|
||||
---
|
||||
|
||||
## Стартовая версия
|
||||
|
||||
Новый модуль начинается с `1.0`.
|
||||
Версия `0.x` не используется.
|
||||
|
||||
---
|
||||
|
||||
## Инструкция для агентов (Codex, Claude)
|
||||
|
||||
**Обязательно при каждом коммите:**
|
||||
|
||||
1. Определи, к какому модулю относятся изменения.
|
||||
2. Прочитай текущую версию модуля из канонического места (константа, заголовок, конфиг).
|
||||
3. Выбери инкремент по правилу:
|
||||
- Изменяется поведение, добавляется или удаляется функциональность → **N+1**, минор сбросить в 0
|
||||
- Только исправление бага, поведение не меняется → **N+0.1**
|
||||
4. Обнови версию в коде до коммита.
|
||||
5. Включи новую версию в сообщение коммита: `feat(parser): add csv dialect — v2.0`
|
||||
|
||||
**Агент не должен делать коммит без обновления версии затронутого модуля.**
|
||||
|
||||
### Дерево решений
|
||||
|
||||
```
|
||||
Изменения в модуле?
|
||||
│
|
||||
├── Да — это багфикс (логика была неверной, интерфейс не менялся)?
|
||||
│ ├── Да → N+0.1
|
||||
│ └── Нет → N+1, сброс минора в 0
|
||||
│
|
||||
└── Нет изменений в модуле → версия не меняется
|
||||
```
|
||||
- Module version format is `N.M`.
|
||||
- New modules start at `1.0`. `0.x` is not used.
|
||||
- Any functional change bumps the major version and resets minor to `0`.
|
||||
- A narrow bugfix that does not change behavior or interface bumps minor by `0.1`.
|
||||
- Store the version in one canonical place only: code constant, module document header, or config field.
|
||||
- If the module is tagged separately, use `<module-name>/v<N.M>`.
|
||||
- Do not create a tag without updating the module's canonical version first.
|
||||
- When a commit changes a module, update that module's version in the same commit.
|
||||
|
||||
42
rules/patterns/no-hardcoded-vendors/README.md
Normal file
42
rules/patterns/no-hardcoded-vendors/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# No Hardcoded Vendors Pattern Notes
|
||||
|
||||
This file keeps examples. The normative rules live in `contract.md`.
|
||||
|
||||
## Запрещено
|
||||
|
||||
```go
|
||||
if device.Vendor == "Dell" { ... }
|
||||
if strings.Contains(model, "PowerEdge") { ... }
|
||||
switch vendor {
|
||||
case "HP", "HPE", "Hewlett Packard": ...
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Запрещено — список вендоров в коде
|
||||
var knownVendors = []string{"Dell", "HP", "Cisco", "Lenovo"}
|
||||
```
|
||||
|
||||
## Правильно
|
||||
|
||||
```go
|
||||
// Смотрим на возможности объекта, не на имя вендора
|
||||
if device.HasIPMI { ... }
|
||||
if device.ParserType == "redfish" { ... }
|
||||
```
|
||||
|
||||
Маппинг в конфиге:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
vendor_parsers:
|
||||
dell: redfish
|
||||
hp: ilo
|
||||
cisco: ucs
|
||||
```
|
||||
|
||||
Маппинг в БД:
|
||||
|
||||
```sql
|
||||
SELECT parser_type FROM vendor_registry WHERE LOWER(vendor) = LOWER(?);
|
||||
```
|
||||
@@ -1,64 +1,23 @@
|
||||
# Contract: No Hardcoded Vendors or Models
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
## Purpose
|
||||
|
||||
Запрет на хардкод названий вендоров, моделей и партномеров в коде.
|
||||
|
||||
---
|
||||
See `README.md` for code examples.
|
||||
|
||||
## Правило
|
||||
|
||||
Названия вендоров, моделей, серий оборудования и партномеров **не появляются в коде**.
|
||||
Они приходят из данных: БД, конфига, входного документа, справочника.
|
||||
|
||||
---
|
||||
|
||||
## Что запрещено
|
||||
|
||||
```go
|
||||
// Запрещено
|
||||
if device.Vendor == "Dell" { ... }
|
||||
if strings.Contains(model, "PowerEdge") { ... }
|
||||
switch vendor {
|
||||
case "HP", "HPE", "Hewlett Packard": ...
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Запрещено — список вендоров в коде
|
||||
var knownVendors = []string{"Dell", "HP", "Cisco", "Lenovo"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что делать вместо
|
||||
|
||||
Логика определяется по полям из данных, не по названию вендора:
|
||||
|
||||
```go
|
||||
// Правильно — смотрим на возможности объекта, не на имя вендора
|
||||
if device.HasIPMI { ... }
|
||||
if device.ParserType == "redfish" { ... }
|
||||
```
|
||||
|
||||
Если нужен маппинг — он живёт в конфиге или справочной таблице БД, не в коде:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
vendor_parsers:
|
||||
dell: redfish
|
||||
hp: ilo
|
||||
cisco: ucs
|
||||
```
|
||||
|
||||
```sql
|
||||
-- справочник в БД
|
||||
SELECT parser_type FROM vendor_registry WHERE LOWER(vendor) = LOWER(?);
|
||||
```
|
||||
|
||||
---
|
||||
- Запрещены сравнения и switch по имени вендора (`if device.Vendor == "Dell"`) и
|
||||
списки вендоров в коде (`var knownVendors = []string{...}`).
|
||||
- Логика определяется по полям из данных (`device.HasIPMI`, `device.ParserType`),
|
||||
не по названию вендора.
|
||||
- Если нужен маппинг вендор → поведение, он живёт в конфиге или справочной таблице БД.
|
||||
|
||||
## Исключения
|
||||
|
||||
@@ -70,8 +29,6 @@ SELECT parser_type FROM vendor_registry WHERE LOWER(vendor) = LOWER(?);
|
||||
|
||||
В этих местах название вендора — идентификатор модуля, не условие в логике.
|
||||
|
||||
---
|
||||
|
||||
## Почему
|
||||
|
||||
Хардкод вендора делает код хрупким: новый вендор требует правок в коде, а не в данных.
|
||||
|
||||
84
rules/patterns/release-signing/README.md
Normal file
84
rules/patterns/release-signing/README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Release Signing Pattern Notes
|
||||
|
||||
This file keeps examples and rationale. The normative rules live in `contract.md`.
|
||||
|
||||
## Keys Repository Shape
|
||||
|
||||
```text
|
||||
keys/
|
||||
developers/
|
||||
<name>.pub
|
||||
scripts/
|
||||
keygen.sh
|
||||
sign-release.sh
|
||||
verify-signature.sh
|
||||
```
|
||||
|
||||
## Runtime Trust Loader
|
||||
|
||||
```go
|
||||
// trustedKeysRaw is injected via -ldflags.
|
||||
// Format: base64(key1):base64(key2):...
|
||||
var trustedKeysRaw string
|
||||
```
|
||||
|
||||
Typical parsing pattern:
|
||||
|
||||
```go
|
||||
func trustedKeys() ([]ed25519.PublicKey, error) {
|
||||
if trustedKeysRaw == "" {
|
||||
return nil, fmt.Errorf("dev build: trusted keys not embedded, updates disabled")
|
||||
}
|
||||
var keys []ed25519.PublicKey
|
||||
for _, enc := range strings.Split(trustedKeysRaw, ":") {
|
||||
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
|
||||
if err != nil || len(b) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("invalid trusted key: %w", err)
|
||||
}
|
||||
keys = append(keys, ed25519.PublicKey(b))
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Build Example
|
||||
|
||||
```sh
|
||||
KEYS=$(paste -sd: /path/to/keys/developers/*.pub)
|
||||
go build \
|
||||
-ldflags "-s -w -X <module>/internal/updater.trustedKeysRaw=${KEYS}" \
|
||||
-o dist/<binary>-linux-amd64 \
|
||||
./cmd/<binary>
|
||||
```
|
||||
|
||||
## Verification Sketch
|
||||
|
||||
```go
|
||||
func verifySignature(binaryPath, sigPath string) error {
|
||||
keys, err := trustedKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read binary: %w", err)
|
||||
}
|
||||
sig, err := os.ReadFile(sigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read signature: %w", err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if ed25519.Verify(key, data, sig) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("signature verification failed: no trusted key matched")
|
||||
}
|
||||
```
|
||||
|
||||
## Release Assets
|
||||
|
||||
```text
|
||||
<binary>-linux-amd64
|
||||
<binary>-linux-amd64.sig
|
||||
```
|
||||
@@ -8,142 +8,16 @@ Ed25519 asymmetric signing for Go release binaries.
|
||||
Guarantees that a binary accepted by a running application was produced by a trusted developer.
|
||||
Applies to any Go binary that is distributed or supports self-update.
|
||||
|
||||
---
|
||||
|
||||
## Key Management
|
||||
|
||||
Public keys are stored in the centralized keys repository: `git.mchus.pro/mchus/keys`
|
||||
|
||||
```
|
||||
keys/
|
||||
developers/
|
||||
<name>.pub ← raw Ed25519 public key, base64-encoded, one line per developer
|
||||
scripts/
|
||||
keygen.sh ← generates keypair
|
||||
sign-release.sh ← signs a binary
|
||||
verify-signature.sh ← verifies locally
|
||||
```
|
||||
|
||||
Public keys are safe to commit. Private keys stay on each developer's machine — never committed, never shared.
|
||||
|
||||
**Adding a developer:** add their `.pub` file → commit → rebuild affected releases.
|
||||
**Removing a developer:** delete their `.pub` file → commit → rebuild releases.
|
||||
Previously signed binaries with their key remain valid (already distributed), but they cannot sign new releases.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Key Trust Model
|
||||
|
||||
A binary is accepted if its signature verifies against **any** of the embedded trusted public keys.
|
||||
This mirrors the SSH `authorized_keys` model.
|
||||
|
||||
- One developer signs a release with their private key → produces one `.sig` file.
|
||||
- The binary trusts all active developers — any of them can make a valid release.
|
||||
- Signature format: raw 64-byte Ed25519 signature (not PEM, not armored).
|
||||
|
||||
---
|
||||
|
||||
## Embedding Keys at Build Time
|
||||
|
||||
Public keys are injected via `-ldflags` at release build time — not hardcoded at compile time.
|
||||
This allows adding/removing developers without changing application source code.
|
||||
|
||||
```go
|
||||
// internal/updater/trust.go
|
||||
|
||||
// trustedKeysRaw is injected at build time via -ldflags.
|
||||
// Format: base64(key1):base64(key2):...
|
||||
// Empty string = dev build, updates disabled.
|
||||
var trustedKeysRaw string
|
||||
|
||||
func trustedKeys() ([]ed25519.PublicKey, error) {
|
||||
if trustedKeysRaw == "" {
|
||||
return nil, fmt.Errorf("dev build: trusted keys not embedded, updates disabled")
|
||||
}
|
||||
var keys []ed25519.PublicKey
|
||||
for _, enc := range strings.Split(trustedKeysRaw, ":") {
|
||||
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
|
||||
if err != nil || len(b) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("invalid trusted key: %w", err)
|
||||
}
|
||||
keys = append(keys, ed25519.PublicKey(b))
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
```
|
||||
|
||||
Release build script injects all current developer keys:
|
||||
|
||||
```sh
|
||||
# scripts/build-release.sh
|
||||
KEYS=$(paste -sd: /path/to/keys/developers/*.pub)
|
||||
go build \
|
||||
-ldflags "-s -w -X <module>/internal/updater.trustedKeysRaw=${KEYS}" \
|
||||
-o dist/<binary>-linux-amd64 \
|
||||
./cmd/<binary>
|
||||
```
|
||||
|
||||
Dev build (no `-ldflags` injection): `trustedKeysRaw` is empty → updates disabled, binary works normally.
|
||||
|
||||
---
|
||||
|
||||
## Signature Verification (stdlib only, no external tools)
|
||||
|
||||
Use `crypto/ed25519` from Go standard library. No third-party dependencies.
|
||||
|
||||
```go
|
||||
// internal/updater/trust.go
|
||||
|
||||
func verifySignature(binaryPath, sigPath string) error {
|
||||
keys, err := trustedKeys()
|
||||
if err != nil {
|
||||
return err // dev build or misconfiguration
|
||||
}
|
||||
data, err := os.ReadFile(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read binary: %w", err)
|
||||
}
|
||||
sig, err := os.ReadFile(sigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read signature: %w", err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if ed25519.Verify(key, data, sig) {
|
||||
return nil // any trusted key accepts → pass
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("signature verification failed: no trusted key matched")
|
||||
}
|
||||
```
|
||||
|
||||
Rejection behavior: log as WARNING, continue with current binary. Never crash, never block operation.
|
||||
|
||||
---
|
||||
|
||||
## Release Asset Convention
|
||||
|
||||
Every release must attach two files to the Gitea release:
|
||||
|
||||
```
|
||||
<binary>-linux-amd64 ← the binary
|
||||
<binary>-linux-amd64.sig ← raw 64-byte Ed25519 signature
|
||||
```
|
||||
|
||||
Signing:
|
||||
|
||||
```sh
|
||||
sh keys/scripts/sign-release.sh <developer-name> dist/<binary>-linux-amd64
|
||||
```
|
||||
|
||||
Both files are uploaded to the Gitea release as downloadable assets.
|
||||
|
||||
---
|
||||
See `README.md` for reference code and build snippets.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never hardcode public keys as string literals in source code — always use ldflags injection.
|
||||
- Never commit private keys (`.key` files) anywhere.
|
||||
- A binary built without ldflags injection must work normally — it just cannot perform verified updates.
|
||||
- Signature verification failure must be a silent logged warning, not a crash or user-visible error.
|
||||
- Use `crypto/ed25519` (stdlib) only — no external signing libraries.
|
||||
- `.sig` file contains raw 64 bytes (not base64, not PEM). Produced by `openssl pkeyutl -sign -rawin`.
|
||||
- Public keys are stored in the centralized keys repository. Public keys may be committed; private keys must stay on each developer machine and must never be committed or shared.
|
||||
- Adding or removing a trusted developer means changing the committed `.pub` set and rebuilding affected releases.
|
||||
- A release is trusted if its signature verifies against any embedded trusted public key.
|
||||
- The `.sig` asset is a raw 64-byte Ed25519 signature, not PEM and not base64.
|
||||
- Trusted public keys must be injected at build time via `-ldflags`. Do not hardcode them in source.
|
||||
- A build without injected keys is a valid dev build. It must continue working normally, but verified updates are disabled.
|
||||
- Signature verification uses Go stdlib `crypto/ed25519` only.
|
||||
- Signature verification failure must log a warning and keep the current binary. It must not crash the app and must not block unrelated operation.
|
||||
- Every signed release must ship the binary and its matching `.sig` asset.
|
||||
|
||||
41
rules/patterns/submodule-integration/contract.md
Normal file
41
rules/patterns/submodule-integration/contract.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Contract: Git Submodule Integration
|
||||
|
||||
Version: 1.0
|
||||
|
||||
Правила для проектов, использующих git submodules (shared libraries, viewers, bible, tooling).
|
||||
|
||||
Применяется в: `bee` (internal/chart/, bible/), `chart`, `logpile/internal/chart/`, `PriceForge`.
|
||||
|
||||
## Основное правило
|
||||
|
||||
**Embedded submodules — read-only с точки зрения host-проекта.**
|
||||
|
||||
## Запрещено
|
||||
|
||||
- Реализовывать project-specific поведение путём редактирования кода submodule.
|
||||
- Вносить в submodule изменения, специфичные для одного host-проекта.
|
||||
- Держать в submodule локальные неотправленные коммиты как часть feature host-проекта.
|
||||
|
||||
## Разрешено
|
||||
|
||||
- Обновлять указатель submodule на upstream-коммит после merge там.
|
||||
- Если нужна новая возможность в submodule — предложить и влить её в upstream как generic-изменение, затем подтянуть через обновление указателя.
|
||||
|
||||
## Когда нужны новые данные
|
||||
|
||||
Если host-проект нуждается в новых данных, которые должен отображать submodule-viewer:
|
||||
|
||||
1. Производить, нормализовывать и сериализовывать новые данные в самом host-проекте.
|
||||
2. Обновить JSON-контракт (например, `bible-local/docs/hardware-ingest-contract.md`), чтобы viewer мог читать их из стандартного snapshot.
|
||||
3. Предложить поддержку нового поля в upstream viewer как generic-изменение.
|
||||
|
||||
## Почему
|
||||
|
||||
Конкретный провал: попытка добавить telemetry storage в `bee` через редактирование `internal/chart/` создала coupling shared viewer с одним host-проектом и риск скрытых регрессий в других проектах, использующих тот же `chart`.
|
||||
|
||||
## Документирование интеграции
|
||||
|
||||
В `bible-local/` host-проекта должен быть явный контракт:
|
||||
- Какие данные ожидает submodule на входе.
|
||||
- Как host-проект их производит (какой модуль/файл).
|
||||
- Текущий upstream commit/tag submodule.
|
||||
@@ -8,19 +8,10 @@ This module is the canonical source for:
|
||||
- icon-first action buttons with deterministic semantics
|
||||
- select/actions narrow edge columns
|
||||
- toolbar+table visual seam behavior (single continuous block)
|
||||
- canonical SVG icon sprite for table actions (shared at theme layer)
|
||||
- canonical table-management visual/interaction seams independent of host branding
|
||||
|
||||
Use this module as the base interaction contract for:
|
||||
|
||||
- `controls-selection`
|
||||
- `operator-tools`
|
||||
Use this module as the base interaction contract for `controls-selection`.
|
||||
|
||||
Pattern-specific contracts should only define additions or exceptions.
|
||||
|
||||
## Canonical Assets
|
||||
|
||||
- Behavior/layout contract: `contract.md`
|
||||
- Active icon sprite: `../theme-vapor/templates/icon_sprite.html`
|
||||
- Active stylesheet baseline: `../theme-vapor/static/vapor.css`
|
||||
- Legacy icon sprite archive: `../theme-aqua-legacy/templates/icon_sprite.html`
|
||||
Visual styling (colors, typography, surfaces) comes from `../web-visual-baseline/`.
|
||||
The behavior/layout contract is `contract.md`.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Contract: Table Management (Shared)
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
## Scope
|
||||
|
||||
Defines one canonical, reusable interaction model for table-driven operator/admin screens.
|
||||
All patterns that expose table selection + bulk actions must inherit this contract.
|
||||
Visual styling is inherited from the active repository baseline (currently Vapor Soft / Vapor Night
|
||||
in demo/scaffold). This contract remains theme-agnostic and defines geometry/semantics only.
|
||||
Visual styling is inherited from the `web-visual-baseline` contract.
|
||||
This contract remains theme-agnostic and defines geometry/semantics only.
|
||||
|
||||
## Canonical Regions
|
||||
|
||||
@@ -91,11 +91,10 @@ Canonical mapping:
|
||||
## Pagination Rules
|
||||
|
||||
- Pagination is server-side. Never load all rows and paginate client-side.
|
||||
- URL query parameters carry page state: `?page=2&per_page=50`.
|
||||
- `page` is 1-based.
|
||||
- `per_page` defaults to a fixed project constant (e.g. 50); user may change it from a fixed set
|
||||
(25 / 50 / 100).
|
||||
- The server response includes: `total_count`, `page`, `per_page`, `total_pages`.
|
||||
- Query parameters carry page state (`?page=2&per_page=50`); the response includes
|
||||
`total_count`, `page`, `per_page`, `total_pages` (shape per the `go-api` contract).
|
||||
- `page` is 1-based; `per_page` defaults to a fixed project constant (e.g. 50) and may be
|
||||
changed from a fixed set (25 / 50 / 100).
|
||||
- Display: "Showing 51–100 of 342" — always show the range and total.
|
||||
- Prev/Next buttons are disabled (not hidden) at the boundary pages.
|
||||
- Direct page-number input is optional; if present it clamps to `[1, total_pages]` on blur.
|
||||
|
||||
26
rules/patterns/testing-policy/README.md
Normal file
26
rules/patterns/testing-policy/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Testing Policy Pattern Notes
|
||||
|
||||
This file keeps examples. The normative rules live in `contract.md`.
|
||||
|
||||
## Табличный тест
|
||||
|
||||
```go
|
||||
func TestParseGPUSensor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
xml string
|
||||
want int
|
||||
}{
|
||||
{"normal", `<VALUE>290</VALUE>`, 29},
|
||||
{"zero", `<VALUE>0</VALUE>`, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseGPUTemp(tt.xml)
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,13 +1,12 @@
|
||||
# Contract: Testing Policy
|
||||
|
||||
Version: 1.1
|
||||
Version: 1.2
|
||||
|
||||
## Purpose
|
||||
|
||||
Определяет когда писать тесты, когда не писать, и как их поддерживать.
|
||||
Применяется ко всем проектам на Go. Агенты следуют этим правилам самостоятельно, без запроса подтверждения.
|
||||
|
||||
---
|
||||
Применяется ко всем проектам на Go. Агенты следуют этим правилам самостоятельно,
|
||||
без запроса подтверждения. See `README.md` for a reference table test.
|
||||
|
||||
## Когда тест обязателен
|
||||
|
||||
@@ -24,11 +23,9 @@ Version: 1.1
|
||||
|
||||
Для local-first desktop приложений правила деградированных состояний и recovery-тестов определяются также `local-first-recovery` contract.
|
||||
|
||||
---
|
||||
|
||||
## Когда тест не нужен
|
||||
|
||||
Тест не пишется на код, где он не даёт ценности:
|
||||
Тест не пишется на код, где он не даёт ценности. Не писать его и не упоминать его отсутствие:
|
||||
|
||||
- Геттеры и сеттеры: `func (s *Server) Port() int { return s.port }`
|
||||
- Конфиг-структуры и константы
|
||||
@@ -36,50 +33,15 @@ Version: 1.1
|
||||
- Логирование и форматирование вывода
|
||||
- HTTP-хендлеры без бизнес-логики (только роутинг и вызов сервиса)
|
||||
|
||||
---
|
||||
|
||||
## Структура теста
|
||||
|
||||
Использовать стандартный Go `testing`. Табличные тесты (`[]struct{ ... }`) — когда случаев больше двух.
|
||||
|
||||
```go
|
||||
func TestParseGPUSensor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
xml string
|
||||
want int
|
||||
}{
|
||||
{"normal", `<VALUE>290</VALUE>`, 29},
|
||||
{"zero", `<VALUE>0</VALUE>`, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseGPUTemp(tt.xml)
|
||||
if got != tt.want {
|
||||
t.Fatalf("got %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Фикстуры (XML, JSON, бинарные данные) — инлайн-константы или файлы в `testdata/`.
|
||||
Не использовать реальные сетевые вызовы и реальную БД в юнит-тестах.
|
||||
|
||||
---
|
||||
- Стандартный Go `testing`. Табличные тесты (`[]struct{ ... }`) — когда случаев больше двух.
|
||||
- Фикстуры (XML, JSON, бинарные данные) — инлайн-константы или файлы в `testdata/`.
|
||||
- Не использовать реальные сетевые вызовы и реальную БД в юнит-тестах.
|
||||
|
||||
## Мейнтейнс
|
||||
|
||||
- Сломанный тест — чинится или удаляется в том же коммите где сломался.
|
||||
- Закомментированный тест — не допускается. Если тест неактуален — удалить.
|
||||
- Тест, проверяющий удалённую функциональность — удалить вместе с функциональностью.
|
||||
|
||||
---
|
||||
|
||||
## Инструкция для агентов (Codex, Claude)
|
||||
|
||||
1. При добавлении функциональности — проверь по списку выше, попадает ли код в категорию "обязателен".
|
||||
2. Если да — напиши тест в том же коммите, без запроса подтверждения.
|
||||
3. Если нет — не пиши тест, не упоминай его отсутствие.
|
||||
4. При удалении функциональности — удали соответствующие тесты.
|
||||
5. При обнаружении закомментированных или сломанных тестов — удали или почини.
|
||||
- При удалении функциональности — удалить соответствующие тесты в том же коммите.
|
||||
|
||||
80
rules/patterns/unattended-boot-services/README.md
Normal file
80
rules/patterns/unattended-boot-services/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Unattended Boot Services Pattern Notes
|
||||
|
||||
This file keeps examples and rationale. The normative rules live in `contract.md`.
|
||||
|
||||
## Dependency Skeleton
|
||||
|
||||
```sh
|
||||
depend() {
|
||||
need localmount
|
||||
after some-service
|
||||
use logger
|
||||
}
|
||||
```
|
||||
|
||||
Avoid `need net` for best-effort services.
|
||||
|
||||
## Network-Independent SSH
|
||||
|
||||
```sh
|
||||
#!/sbin/openrc-run
|
||||
description="SSH server"
|
||||
|
||||
depend() {
|
||||
need localmount
|
||||
after bee-sshsetup
|
||||
use logger
|
||||
}
|
||||
|
||||
start() {
|
||||
check_config || return 1
|
||||
ebegin "Starting dropbear"
|
||||
/usr/sbin/dropbear ${DROPBEAR_OPTS}
|
||||
eend $?
|
||||
}
|
||||
```
|
||||
|
||||
Place this in `etc/init.d/dropbear` in the overlay to override package defaults that require network.
|
||||
|
||||
## Persistent DHCP
|
||||
|
||||
Wrong:
|
||||
|
||||
```sh
|
||||
udhcpc -i "$iface" -t 3 -T 5 -n -q
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```sh
|
||||
udhcpc -i "$iface" -b -t 0 -T 3 -q
|
||||
```
|
||||
|
||||
## Typical Start Order
|
||||
|
||||
```text
|
||||
localmount
|
||||
-> sshsetup
|
||||
-> dropbear
|
||||
-> network
|
||||
-> nvidia
|
||||
-> audit
|
||||
```
|
||||
|
||||
Use `after` for ordering without turning soft dependencies into hard boot blockers.
|
||||
|
||||
## Error Handling Skeleton
|
||||
|
||||
```sh
|
||||
start() {
|
||||
ebegin "Running audit"
|
||||
/usr/local/bin/audit --output /var/log/audit.json >> /var/log/audit.log 2>&1
|
||||
local rc=$?
|
||||
if [ $rc -eq 0 ]; then
|
||||
einfo "Audit complete"
|
||||
else
|
||||
ewarn "Audit finished with errors — check /var/log/audit.log"
|
||||
fi
|
||||
eend 0
|
||||
}
|
||||
```
|
||||
@@ -7,127 +7,16 @@ Version: 1.0
|
||||
Rules for OpenRC services that run in unattended environments: LiveCDs, kiosks, embedded systems.
|
||||
No user is present. No TTY prompts. Every failure path must have a silent fallback.
|
||||
|
||||
---
|
||||
|
||||
## Core Invariants
|
||||
|
||||
- **Never block boot.** A service failure must not prevent other services from starting.
|
||||
- **Never prompt.** No `read`, no `pause`, no interactive input of any kind.
|
||||
- **Always exit 0.** Use `eend 0` at the end of `start()` regardless of the operation result.
|
||||
- **Log everything.** Write results to `/var/log/` so SSH inspection is possible after boot.
|
||||
- **Fail silently, degrade gracefully.** Missing tool → skip that collector. No network → skip network-dependent steps.
|
||||
|
||||
---
|
||||
|
||||
## Service Dependencies
|
||||
|
||||
Use the minimum necessary dependencies:
|
||||
|
||||
```sh
|
||||
depend() {
|
||||
need localmount # almost always needed
|
||||
after some-service # ordering without hard dependency
|
||||
use logger # optional soft dependency
|
||||
# DO NOT add: need net, need networking, need network-online
|
||||
}
|
||||
```
|
||||
|
||||
**Never use `need net` or `need networking`** unless the service is genuinely useless without
|
||||
network and you want it to fail loudly when no cable is connected.
|
||||
For services that work with or without network, use `after` instead.
|
||||
|
||||
---
|
||||
|
||||
## Network-Independent SSH
|
||||
|
||||
Dropbear (and any SSH server) must start without network being available.
|
||||
Common mistake: installing dropbear-openrc which adds `need net` in its default init.
|
||||
|
||||
Override with a custom init:
|
||||
|
||||
```sh
|
||||
#!/sbin/openrc-run
|
||||
description="SSH server"
|
||||
|
||||
depend() {
|
||||
need localmount
|
||||
after bee-sshsetup # key/user setup, not networking
|
||||
use logger
|
||||
# NO need net
|
||||
}
|
||||
|
||||
start() {
|
||||
check_config || return 1
|
||||
ebegin "Starting dropbear"
|
||||
/usr/sbin/dropbear ${DROPBEAR_OPTS}
|
||||
eend $?
|
||||
}
|
||||
```
|
||||
|
||||
Place this file in the overlay at `etc/init.d/dropbear` — it overrides the package-installed version.
|
||||
|
||||
---
|
||||
|
||||
## Persistent DHCP
|
||||
|
||||
Do not use blocking DHCP (`-n` flag exits if no offer). Use background mode so the client
|
||||
retries automatically when a cable is connected after boot:
|
||||
|
||||
```sh
|
||||
# Wrong — exits immediately if no DHCP offer
|
||||
udhcpc -i "$iface" -t 3 -T 5 -n -q
|
||||
|
||||
# Correct — background daemon, retries indefinitely
|
||||
udhcpc -i "$iface" -b -t 0 -T 3 -q
|
||||
```
|
||||
|
||||
The network service itself should complete immediately (exit 0) — udhcpc daemons run in background.
|
||||
|
||||
---
|
||||
|
||||
## Service Start Order (typical LiveCD)
|
||||
|
||||
```
|
||||
localmount
|
||||
└── sshsetup (user creation, key injection — before dropbear)
|
||||
└── dropbear (SSH — independent of network)
|
||||
└── network (DHCP on all interfaces — does not block anything)
|
||||
└── nvidia (or other hardware init — after network in case firmware needs it)
|
||||
└── audit (main workload — after all hardware ready)
|
||||
```
|
||||
|
||||
Services at the same level can start in parallel. Use `after` not `need` for ordering without hard dependency.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling in start()
|
||||
|
||||
```sh
|
||||
start() {
|
||||
ebegin "Running audit"
|
||||
/usr/local/bin/audit --output /var/log/audit.json >> /var/log/audit.log 2>&1
|
||||
local rc=$?
|
||||
if [ $rc -eq 0 ]; then
|
||||
einfo "Audit complete"
|
||||
else
|
||||
ewarn "Audit finished with errors — check /var/log/audit.log"
|
||||
fi
|
||||
eend 0 # always 0 — never fail the runlevel
|
||||
}
|
||||
```
|
||||
|
||||
- Capture exit code into a local variable.
|
||||
- Log the result with `einfo` or `ewarn`.
|
||||
- Always `eend 0` — a failed audit is not a reason to block the boot runlevel.
|
||||
- The exception: services whose failure makes SSH impossible (e.g. key setup) may `return 1`.
|
||||
|
||||
---
|
||||
See `README.md` for sample init scripts and ordering sketches.
|
||||
|
||||
## Rules
|
||||
|
||||
- Every `start()` ends with `eend 0` unless failure makes the entire environment unusable.
|
||||
- Network is always best-effort. Test for it, don't depend on it.
|
||||
- Proprietary drivers (NVIDIA, etc.): load failure → log warning → continue without enrichment.
|
||||
- External tools (ipmitool, smartctl, etc.): not-found → skip that data source → do not abort.
|
||||
- Timeout all external commands: `timeout 30 smartctl ...` prevents infinite hangs.
|
||||
- Write all output to `/var/log/` — TTY output is secondary.
|
||||
- Never block boot. A service failure must not stop the rest of the runlevel.
|
||||
- Never prompt. Do not use `read`, pause logic, or any interactive fallback.
|
||||
- Every `start()` must end with `eend 0` unless failure makes the environment fundamentally unusable, such as breaking SSH setup.
|
||||
- Write service diagnostics to `/var/log/`. TTY output is secondary.
|
||||
- Missing tools, absent network, or driver load failures must degrade gracefully: log and continue.
|
||||
- Use the minimum dependency set. Prefer `after` and `use`; do not add `need net`, `need networking`, or `need network-online` unless the service is truly useless without network and failure should be loud.
|
||||
- SSH services must start without requiring network availability.
|
||||
- DHCP must be non-blocking and persistent. Run the client in background retry mode rather than failing the boot sequence when no lease is immediately available.
|
||||
- External commands must be timeout-bounded so a bad device or tool cannot hang boot indefinitely.
|
||||
|
||||
46
rules/patterns/vendor-installer-verification/README.md
Normal file
46
rules/patterns/vendor-installer-verification/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Vendor Installer Verification Pattern Notes
|
||||
|
||||
This file keeps examples. The normative rules live in `contract.md`.
|
||||
|
||||
## Download Order
|
||||
|
||||
```sh
|
||||
BASE_URL="https://vendor.example.com/downloads/${VERSION}"
|
||||
BIN_FILE="/var/cache/vendor-${VERSION}.run"
|
||||
SHA_FILE="/var/cache/vendor-${VERSION}.run.sha256sum"
|
||||
|
||||
# 1. Download checksum first
|
||||
wget -q -O "$SHA_FILE" "${BASE_URL}/vendor-${VERSION}.run.sha256sum"
|
||||
|
||||
# 2. Download installer
|
||||
wget --show-progress -O "$BIN_FILE" "${BASE_URL}/vendor-${VERSION}.run"
|
||||
|
||||
# 3. Verify
|
||||
cd /var/cache
|
||||
sha256sum -c "$SHA_FILE" || { echo "ERROR: sha256 mismatch"; rm -f "$BIN_FILE"; exit 1; }
|
||||
```
|
||||
|
||||
## Cache with Verification
|
||||
|
||||
```sh
|
||||
verify_cached() {
|
||||
[ -s "$SHA_FILE" ] || return 1 # sha256 file missing or empty
|
||||
[ -s "$BIN_FILE" ] || return 1 # binary missing or empty
|
||||
cd "$(dirname "$BIN_FILE")"
|
||||
sha256sum -c "$SHA_FILE" --status 2>/dev/null
|
||||
}
|
||||
|
||||
if ! verify_cached; then
|
||||
rm -f "$BIN_FILE" "$SHA_FILE"
|
||||
# ... download and verify
|
||||
else
|
||||
echo "verified from cache"
|
||||
fi
|
||||
```
|
||||
|
||||
## Version Validation
|
||||
|
||||
```sh
|
||||
curl -sIL "https://vendor.example.com/downloads/${VERSION}/installer.run" \
|
||||
| grep -i 'http/\|content-length'
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# Contract: Vendor Installer Verification
|
||||
|
||||
Version: 1.0
|
||||
Version: 1.1
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -8,75 +8,19 @@ Rules for downloading and verifying proprietary vendor installers (`.run`, `.exe
|
||||
where the vendor publishes a checksum alongside the binary.
|
||||
Applies to: NVIDIA drivers, vendor CLI tools, firmware packages.
|
||||
|
||||
---
|
||||
|
||||
## Download Order
|
||||
|
||||
Always download the checksum file **before** the installer:
|
||||
|
||||
```sh
|
||||
BASE_URL="https://vendor.example.com/downloads/${VERSION}"
|
||||
BIN_FILE="/var/cache/vendor-${VERSION}.run"
|
||||
SHA_FILE="/var/cache/vendor-${VERSION}.run.sha256sum"
|
||||
|
||||
# 1. Download checksum first
|
||||
wget -q -O "$SHA_FILE" "${BASE_URL}/vendor-${VERSION}.run.sha256sum"
|
||||
|
||||
# 2. Download installer
|
||||
wget --show-progress -O "$BIN_FILE" "${BASE_URL}/vendor-${VERSION}.run"
|
||||
|
||||
# 3. Verify
|
||||
cd /var/cache
|
||||
sha256sum -c "$SHA_FILE" || { echo "ERROR: sha256 mismatch"; rm -f "$BIN_FILE"; exit 1; }
|
||||
```
|
||||
|
||||
Reason: if the download is interrupted, you have the expected checksum to verify against on retry.
|
||||
|
||||
---
|
||||
|
||||
## Cache with Verification
|
||||
|
||||
Never assume a cached file is valid — a previous download may have been interrupted (0-byte file):
|
||||
|
||||
```sh
|
||||
verify_cached() {
|
||||
[ -s "$SHA_FILE" ] || return 1 # sha256 file missing or empty
|
||||
[ -s "$BIN_FILE" ] || return 1 # binary missing or empty
|
||||
cd "$(dirname "$BIN_FILE")"
|
||||
sha256sum -c "$SHA_FILE" --status 2>/dev/null
|
||||
}
|
||||
|
||||
if ! verify_cached; then
|
||||
rm -f "$BIN_FILE" "$SHA_FILE"
|
||||
# ... download and verify
|
||||
else
|
||||
echo "verified from cache"
|
||||
fi
|
||||
```
|
||||
|
||||
**Never check only for file existence.** Check that the file is non-empty (`-s`) AND passes checksum.
|
||||
|
||||
---
|
||||
|
||||
## Version Validation
|
||||
|
||||
Before writing build scripts, verify the version URL actually exists:
|
||||
|
||||
```sh
|
||||
curl -sIL "https://vendor.example.com/downloads/${VERSION}/installer.run" \
|
||||
| grep -i 'http/\|content-length'
|
||||
```
|
||||
|
||||
A `404` or `content-length: 0` means the version does not exist on that CDN.
|
||||
Vendor version numbering may have gaps (e.g. NVIDIA skips minor versions on some CDNs).
|
||||
|
||||
---
|
||||
See `README.md` for shell snippets.
|
||||
|
||||
## Rules
|
||||
|
||||
- Download checksum before installer — never after.
|
||||
- Download the checksum file **before** the installer — never after. If the download is
|
||||
interrupted, you still have the expected checksum to verify against on retry.
|
||||
- Verify checksum before extracting or executing.
|
||||
- On mismatch: delete the file, exit with error. Never proceed with a bad installer.
|
||||
- Never assume a cached file is valid — a previous download may have been interrupted.
|
||||
**Never check only for file existence**: the file must be non-empty (`-s`) AND pass checksum.
|
||||
- Cache by `version` + any secondary key (e.g. kernel version for compiled modules).
|
||||
- Before writing build scripts, verify the version URL actually exists (`curl -sIL`).
|
||||
A `404` or `content-length: 0` means the version is absent on that CDN; vendor version
|
||||
numbering may have gaps.
|
||||
- Never commit installer files to git — always download at build time.
|
||||
- Log the expected hash when downloading so failures are diagnosable.
|
||||
|
||||
12
rules/patterns/web-visual-baseline/README.md
Normal file
12
rules/patterns/web-visual-baseline/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Web Visual Baseline — Reference Assets
|
||||
|
||||
The normative rules live in `contract.md`.
|
||||
|
||||
Canonical reference files (full, production version of the style) are in `assets/`:
|
||||
|
||||
- `assets/view.css` — copy this as the starting stylesheet and adapt tokens
|
||||
- `assets/view.html` — reference page structure (header, panels, tables)
|
||||
- `assets/upload.html` — reference upload/open panel
|
||||
|
||||
Do not maintain a separate "starter" copy of the CSS anywhere; `assets/view.css` is the
|
||||
single source. For a new app, copy it and delete unused rules.
|
||||
35
rules/patterns/web-visual-baseline/assets/upload.html
Normal file
35
rules/patterns/web-visual-baseline/assets/upload.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/view.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
<h1>{{ .Title }}</h1>
|
||||
</header>
|
||||
|
||||
<main class="page-main">
|
||||
<section class="upload-panel">
|
||||
<h2>Open Snapshot</h2>
|
||||
<p>Select a Reanimator JSON snapshot to render.</p>
|
||||
<form method="post" action="/render" enctype="multipart/form-data">
|
||||
<label class="upload-dropzone" for="snapshot_file">
|
||||
<input id="snapshot_file" name="snapshot_file" type="file" accept=".json,application/json" required>
|
||||
<span class="upload-eyebrow">Standalone Mode</span>
|
||||
<strong>Choose a snapshot JSON file</strong>
|
||||
<span>The file is rendered read-only and not modified.</span>
|
||||
</label>
|
||||
<div class="upload-actions">
|
||||
<button type="submit">Render Snapshot</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ if .Error }}
|
||||
<div class="error-box">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
476
rules/patterns/web-visual-baseline/assets/view.css
Normal file
476
rules/patterns/web-visual-baseline/assets/view.css
Normal file
@@ -0,0 +1,476 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f9fafb;
|
||||
--border: rgba(34, 36, 38, 0.15);
|
||||
--border-lite: rgba(34, 36, 38, 0.1);
|
||||
--ink: rgba(0, 0, 0, 0.87);
|
||||
--muted: rgba(0, 0, 0, 0.6);
|
||||
--accent: #2185d0;
|
||||
--accent-dark: #1678c2;
|
||||
--accent-bg: #dff0ff;
|
||||
--crit-border: #e0b4b4;
|
||||
--ok-bg: #fcfff5; --ok-fg: #2c662d;
|
||||
--warn-bg: #fffaf3; --warn-fg: #573a08;
|
||||
--crit-bg: #fff6f6; --crit-fg: #9f3a38;
|
||||
--unknown-bg: #f9fafb; --unknown-fg: rgba(0, 0, 0, 0.5);
|
||||
--empty-bg: #f9fafb; --empty-fg: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 14px/1.5 Lato, "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────── */
|
||||
|
||||
.page-header {
|
||||
background: #1b1c1d;
|
||||
padding: 14px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* ── Main layout ─────────────────────────────────── */
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-action {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.header-action:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
width: min(1500px, calc(100vw - 48px));
|
||||
margin: 28px auto 56px;
|
||||
}
|
||||
|
||||
/* ── Meta-panel and upload — классические карточки ── */
|
||||
|
||||
.empty-panel,
|
||||
.meta-panel,
|
||||
.notice-panel,
|
||||
.upload-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15);
|
||||
overflow: hidden;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.empty-panel h2,
|
||||
.meta-panel h2,
|
||||
.notice-panel h2,
|
||||
.upload-panel h2 {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 13px 16px;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.empty-panel p,
|
||||
.notice-panel p,
|
||||
.upload-panel p {
|
||||
margin: 0;
|
||||
padding: 12px 16px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-panel p:last-child {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Section cards — heading + table, без обёртки ─── */
|
||||
|
||||
.section-card {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
overflow: visible;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-card h2 {
|
||||
display: block;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
}
|
||||
|
||||
/* таблица внутри section-card получает свой бордер */
|
||||
.section-card .table-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.section-card .kv-table {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px 0 rgba(34, 36, 38, 0.15);
|
||||
}
|
||||
|
||||
/* ── Upload ──────────────────────────────────────── */
|
||||
|
||||
.upload-panel {
|
||||
width: min(520px, 100%);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.upload-dropzone {
|
||||
display: block;
|
||||
margin: 12px 16px 0;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
background: var(--surface-2);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.1s ease, background 0.1s ease;
|
||||
}
|
||||
|
||||
.upload-dropzone:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.upload-dropzone input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.upload-eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.upload-dropzone strong {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.upload-dropzone span:last-child {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
.upload-actions button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 18px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.upload-actions button:hover {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
.upload-actions button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Error ───────────────────────────────────────── */
|
||||
|
||||
.error-box {
|
||||
margin: 12px 16px;
|
||||
border: 1px solid var(--crit-border);
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
background: var(--crit-bg);
|
||||
color: var(--crit-fg);
|
||||
}
|
||||
|
||||
/* ── Sections grid ───────────────────────────────── */
|
||||
|
||||
.sections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 32px;
|
||||
}
|
||||
|
||||
.section-card-half { grid-column: span 1; }
|
||||
.section-card-full { grid-column: 1 / -1; }
|
||||
|
||||
/* ── Tables ──────────────────────────────────────── */
|
||||
|
||||
.kv-table,
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.kv-table th,
|
||||
.kv-table td,
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
vertical-align: top;
|
||||
text-align: left;
|
||||
border-top: 1px solid var(--border-lite);
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
.kv-table tr:first-child th,
|
||||
.kv-table tr:first-child td,
|
||||
.data-table tr:first-child th,
|
||||
.data-table tr:first-child td {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.kv-table th,
|
||||
.data-table th {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border-lite);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.kv-table th {
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
/* table-wrap уже получил border в .section-card .table-wrap */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* для meta-panel table-wrap без дублирования бордера */
|
||||
.meta-panel .table-wrap {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.table-group + .table-group {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-group h3 {
|
||||
margin: 0;
|
||||
padding: 9px 14px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border-lite);
|
||||
}
|
||||
|
||||
.table-block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.table-filter-empty {
|
||||
margin: 10px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.filter-row th {
|
||||
background: var(--surface-2);
|
||||
padding: 5px 8px;
|
||||
border-top: 1px solid var(--border-lite);
|
||||
}
|
||||
|
||||
.col-filter-text {
|
||||
width: 100%;
|
||||
min-width: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 4px 6px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.col-filter-text:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.col-filter-select {
|
||||
width: 100%;
|
||||
min-width: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 3px 4px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.data-table .status-column {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
/* ── Status ──────────────────────────────────────── */
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
font-size: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge::before {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-ok::before { content: '✓'; color: #16ab39; }
|
||||
.status-warning::before { content: '!'; color: #f2711c; }
|
||||
.status-critical::before { content: '✗'; color: #db2828; }
|
||||
.status-unknown::before { content: '?'; color: rgba(0, 0, 0, 0.4); }
|
||||
.status-empty::before { content: '–'; color: rgba(0, 0, 0, 0.3); }
|
||||
|
||||
.severity-info::before { content: 'i'; color: #2185d0; }
|
||||
.severity-warning::before { content: '!'; color: #f2711c; }
|
||||
.severity-error::before { content: '×'; color: #db2828; }
|
||||
.severity-critical::before { content: '✗'; color: #a33333; }
|
||||
.severity-debug::before { content: '•'; color: rgba(0, 0, 0, 0.55); }
|
||||
.severity-unknown::before { content: '?'; color: rgba(0, 0, 0, 0.4); }
|
||||
|
||||
/* ── Responsive ──────────────────────────────────── */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
width: calc(100vw - 24px);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.sections-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.section-card-half,
|
||||
.section-card-full {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/* ── Print / PDF ──────────────────────────────────── */
|
||||
|
||||
@media print {
|
||||
.page-header {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sections-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-card-half,
|
||||
.section-card-full {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
165
rules/patterns/web-visual-baseline/assets/view.html
Normal file
165
rules/patterns/web-visual-baseline/assets/view.html
Normal file
@@ -0,0 +1,165 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ .Title }}</title>
|
||||
<link rel="stylesheet" href="/static/view.css">
|
||||
{{ if not .PrintMode }}<script defer src="/static/view.js"></script>{{ end }}
|
||||
{{ if .PrintMode }}<script>window.onload = function() { window.print(); };</script>{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ if .DownloadArchiveURL }}
|
||||
<div class="header-actions">
|
||||
<a class="header-action" href="{{ .DownloadArchiveURL }}">{{ if .DownloadArchiveLabel }}{{ .DownloadArchiveLabel }}{{ else }}Download archive{{ end }}</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
</header>
|
||||
|
||||
<main class="page-main">
|
||||
{{ if .NoticeTitle }}
|
||||
<section class="notice-panel">
|
||||
<h2>{{ .NoticeTitle }}</h2>
|
||||
<p>{{ .NoticeBody }}</p>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ if .HasSnapshot }}
|
||||
<section class="meta-panel">
|
||||
<h2>Snapshot Metadata</h2>
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{{ range .Meta }}
|
||||
<tr>
|
||||
<th>{{ .Key }}</th>
|
||||
<td>{{ .Value }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="sections-grid">
|
||||
{{ range .Sections }}
|
||||
<section class="section-card {{ if or (eq .ID "board") (eq .ID "firmware") }}section-card-half{{ else }}section-card-full{{ end }}" id="{{ .ID }}">
|
||||
<h2>{{ .Title }}</h2>
|
||||
|
||||
{{ if eq .Kind "object" }}
|
||||
<table class="kv-table">
|
||||
<tbody>
|
||||
{{ range .Rows }}
|
||||
<tr>
|
||||
<th>{{ .Key }}</th>
|
||||
<td>
|
||||
{{ range joinLines .Value }}
|
||||
<div>{{ . }}</div>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .Kind "table" }}
|
||||
{{ $section := . }}
|
||||
<div class="table-block table-filterable">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ range .Columns }}
|
||||
<th data-col="{{ . }}"{{ if or (eq . "status") (eq . "severity_icon") }} class="status-column"{{ end }}{{ if eq . "status" }} aria-label="status"{{ end }}{{ if eq . "severity_icon" }} aria-label="severity"{{ end }}>{{ if and (ne . "status") (ne . "severity_icon") }}{{ . }}{{ end }}</th>
|
||||
{{ end }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Items }}
|
||||
<tr data-severity-row="true" data-severity="{{ .Severity }}">
|
||||
{{ $row := . }}
|
||||
{{ range $section.Columns }}
|
||||
<td{{ if or (eq . "status") (eq . "severity_icon") }} class="status-column"{{ end }}>
|
||||
{{ $value := index $row.Cells . }}
|
||||
{{ if eq . "status" }}
|
||||
<span class="status-badge {{ statusClass $value }}" role="img" aria-label="{{ $value }}" title="{{ $value }}"></span>
|
||||
{{ else if eq . "severity_icon" }}
|
||||
<span class="status-badge {{ severityClass $value }}" role="img" aria-label="{{ $value }}" title="{{ $value }}"></span>
|
||||
{{ else }}
|
||||
{{ range joinLines $value }}
|
||||
<div>{{ . }}</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
{{ end }}
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="table-filter-empty" hidden>No rows match the active filters.</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if eq .Kind "grouped_tables" }}
|
||||
{{ range .Groups }}
|
||||
<div class="table-group">
|
||||
<h3>{{ .Title }}</h3>
|
||||
{{ $group := . }}
|
||||
<div class="table-block table-filterable">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ range .Columns }}
|
||||
<th data-col="{{ . }}"{{ if or (eq . "status") (eq . "severity_icon") }} class="status-column"{{ end }}{{ if eq . "status" }} aria-label="status"{{ end }}{{ if eq . "severity_icon" }} aria-label="severity"{{ end }}>{{ if and (ne . "status") (ne . "severity_icon") }}{{ . }}{{ end }}</th>
|
||||
{{ end }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Items }}
|
||||
<tr data-severity-row="true" data-severity="{{ .Severity }}">
|
||||
{{ $row := . }}
|
||||
{{ range $group.Columns }}
|
||||
<td{{ if or (eq . "status") (eq . "severity_icon") }} class="status-column"{{ end }}>
|
||||
{{ $value := index $row.Cells . }}
|
||||
{{ if eq . "status" }}
|
||||
<span class="status-badge {{ statusClass $value }}" role="img" aria-label="{{ $value }}" title="{{ $value }}"></span>
|
||||
{{ else if eq . "severity_icon" }}
|
||||
<span class="status-badge {{ severityClass $value }}" role="img" aria-label="{{ $value }}" title="{{ $value }}"></span>
|
||||
{{ else }}
|
||||
{{ range joinLines $value }}
|
||||
<div>{{ . }}</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
{{ end }}
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="table-filter-empty" hidden>No rows match the active filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</section>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Error }}
|
||||
<div role="alert" class="error-box">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if not .HasSnapshot }}
|
||||
<section class="empty-panel">
|
||||
<h2>Snapshot Viewer</h2>
|
||||
<p>This page renders one Reanimator snapshot provided by the embedding application.</p>
|
||||
</section>
|
||||
{{ end }}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
94
rules/patterns/web-visual-baseline/contract.md
Normal file
94
rules/patterns/web-visual-baseline/contract.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Contract: Web Visual Baseline
|
||||
|
||||
Version: 1.0
|
||||
|
||||
## Scope
|
||||
|
||||
Defines the default visual baseline for future web applications in this ecosystem.
|
||||
|
||||
This is the single visual style for the ecosystem. The canonical reference files are vendored
|
||||
in this pattern:
|
||||
|
||||
- `assets/view.css`
|
||||
- `assets/view.html`
|
||||
- `assets/upload.html`
|
||||
|
||||
When a project does not already have an established design system, use this baseline by default.
|
||||
Copy `assets/view.css` as the starting stylesheet and adapt tokens; do not rewrite it from prose.
|
||||
|
||||
## Core Direction
|
||||
|
||||
- Prefer a clean, data-first interface over decorative marketing UI.
|
||||
- Default to server-rendered HTML with simple CSS.
|
||||
- Optimize for scanability, density, and operational clarity.
|
||||
- Use restrained visual hierarchy, not novelty effects.
|
||||
- Reuse the baseline directly when possible; copying the canonical CSS and adapting tokens is allowed.
|
||||
|
||||
## Canonical Visual Language
|
||||
|
||||
- Dark application header on top.
|
||||
- White page background and white content surfaces.
|
||||
- Light secondary surfaces for headers and table heads.
|
||||
- Thin gray borders with a subtle shadow.
|
||||
- Small radii (`4px`).
|
||||
- Dense but readable typography (`14px/1.5` baseline).
|
||||
- Blue accent in the `#2185d0` family for primary actions and active accents.
|
||||
- Tables and key-value layouts as the primary presentation pattern.
|
||||
- Status communicated with both text and color.
|
||||
|
||||
## Typography
|
||||
|
||||
- Use `Lato, "Helvetica Neue", Arial, Helvetica, sans-serif` unless a project has an approved alternative.
|
||||
- Page titles are compact and strong, not oversized hero typography.
|
||||
- Section titles should be clear and structural.
|
||||
- Avoid display fonts, novelty fonts, and oversized marketing headings in application UI.
|
||||
|
||||
## Layout Primitives
|
||||
|
||||
- `page-header`: dark global header with page title and compact actions.
|
||||
- `page-main`: centered content area with generous outer margin and bounded max width.
|
||||
- `panel`: white surface with border, light shadow, and simple heading strip.
|
||||
- `section-card`: heading followed by table/content block.
|
||||
- `table-wrap`: horizontal overflow container for dense data tables.
|
||||
|
||||
## Preferred Components
|
||||
|
||||
- Key-value tables for singleton object/detail views.
|
||||
- Dense data tables for repeated records.
|
||||
- Compact upload/open panels when local file input is needed.
|
||||
- Quiet header actions for secondary navigation.
|
||||
- Clear primary buttons for the main action on a screen.
|
||||
- Simple alert/error boxes with border + tinted background.
|
||||
|
||||
## Status Rules
|
||||
|
||||
- `OK`: green
|
||||
- `Warning`: amber
|
||||
- `Critical`: red
|
||||
- `Unknown`: gray
|
||||
- `Empty`: light gray
|
||||
|
||||
Status must not rely on color alone.
|
||||
Show text or another explicit indicator together with the color treatment.
|
||||
|
||||
## Responsive Rules
|
||||
|
||||
- Keep desktop density high.
|
||||
- Collapse grids to one column on small screens.
|
||||
- Preserve table readability with horizontal scrolling instead of destructive cardification by default.
|
||||
- Header actions may wrap or stack on mobile, but should remain compact.
|
||||
|
||||
## Forbidden Drift
|
||||
|
||||
- Do not default to glassmorphism, blurred shells, floating neon gradients, or soft-dribbble styling.
|
||||
- Do not replace dense tables with oversized card grids when the data is inherently tabular.
|
||||
- Do not introduce arbitrary color coding for non-status fields.
|
||||
- Do not use oversized border radii, heavy shadows, or large empty spacing as the default application style.
|
||||
- Do not import a SPA/dashboard aesthetic unless the product explicitly requires it.
|
||||
|
||||
## Relationship To Other UI Contracts
|
||||
|
||||
- Use this contract as the visual baseline.
|
||||
- Use `table-management` for shared table geometry and interaction seams.
|
||||
- Use `controls-selection` for button hierarchy, filters, and bulk selection semantics.
|
||||
- Pattern-specific contracts may override details only when they document the reason.
|
||||
34
scripts/lint.sh
Executable file
34
scripts/lint.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
# Consistency checks for the rules library. Run from the repo root: sh scripts/lint.sh
|
||||
set -u
|
||||
|
||||
fail=0
|
||||
|
||||
# 1. Every pattern directory must be reachable from the bootstrap router.
|
||||
for dir in rules/patterns/*/; do
|
||||
name=$(basename "$dir")
|
||||
if ! grep -q "$name" AGENT-BOOTSTRAP.md; then
|
||||
echo "FAIL: rules/patterns/$name is not mentioned in AGENT-BOOTSTRAP.md"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. Every bible/... path mentioned in the bootstrap must exist in the repo.
|
||||
for ref in $(grep -o 'bible/rules/patterns/[a-z-]*/contract\.md' AGENT-BOOTSTRAP.md | sort -u); do
|
||||
path=${ref#bible/}
|
||||
if [ ! -f "$path" ]; then
|
||||
echo "FAIL: AGENT-BOOTSTRAP.md references missing file $path"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. No machine-local absolute paths or stale path prefixes in committed markdown.
|
||||
if grep -rn '/Users/\|kit/patterns' --include='*.md' . --exclude-dir=.git; then
|
||||
echo "FAIL: machine-local absolute paths or stale kit/patterns references found (see above)"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "OK: all checks passed"
|
||||
fi
|
||||
exit "$fail"
|
||||
Reference in New Issue
Block a user