Skip to content

Latest commit

 

History

History
298 lines (252 loc) · 21.7 KB

contributing.md

File metadata and controls

298 lines (252 loc) · 21.7 KB

Contributing

Monorepo layout

fireflyframework-php publishes the root fireflyframework/larafly library. All component code stays under packages/*; their manifests document module requirements and namespaces and are validated together. They are not published or installed separately. skeleton/ is the bundled application template used by firefly new. samples/lumen consumes the root through a copied path repository.

Production namespaces, autoload files and Laravel discovery live in the root manifest. Component test namespaces and Lumen\ belong in root autoload-dev, so consumers never load repository tests. When adding a component, update both its internal descriptor and the root's dependency/autoload/discovery/ replacement entries. The distribution consumer gate verifies the resulting installation.

Local setup

composer install

Then activate the committed pre-push safety guard for your local clone — this is required, not optional, because the guard's local enforcement (as opposed to CI's, which always runs) depends entirely on this being set:

git config core.hooksPath scripts/hooks

This points git at the repo-tracked scripts/hooks/ directory instead of the default (untracked, per-clone) .git/hooks/, so the committed pre-push hook — which runs scripts/check-no-sensitive-tracked.sh — fires automatically on every git push from this clone.

The gate

Before opening a PR, all of the following must pass:

composer check          # pint --test && phpstan analyse && pest && deptrac analyse
composer mono-validate   # consistency of internal component dependency versions
composer test:package    # exported library, copied Lumen consumer and bundled installer
.venv-docs/bin/mkdocs build --strict   # docs/: link integrity, no orphaned pages
bash scripts/check-no-sensitive-tracked.sh   # the pre-push guard, run directly

composer check is itself the composition of four scripts (pint-test, stan, test, deptrac) — see composer.json's scripts block. Any one of them failing fails the gate. It does not include composer test:browser: test runs the unit testsuite, which excludes tests/Browser, so the default gate never needs Node. The documentation tests are in it — they are ordinary Pest files under tests/ — so a stale code listing or an unlinked module guide fails composer check like any other regression.

composer test:integration runs the integration group against real servers; start them with docker compose -f docker-compose.integration.yml up -d, export the DSNs listed in that file's header, and see Integration Testing.

Browser tests

tests/Browser/ drives the shipped skeleton app in a real Chromium through pestphp/pest-plugin-browser (Playwright). The plugin serves the Testbench-booted app in-process, so a scenario can still assert against the database and the application log. One file per surface:

File What it proves
WelcomeAndApiTest.php the welcome page, a JSON controller, Swagger UI
AdminDashboardTest.php every dashboard page, the theme toggle, dark mode, phone width
AdminDataBrowserTest.php list → filter → record → edit → create → delete → relation
AdminSettingsTest.php a feature-switch round trip
ValidationErrorsTest.php a 422 from the skeleton's POST /orders, worded by the constraint that failed
ErrorPagesDebugTest.php, ErrorPagesProductionTest.php 404/405/500 with and without the trace; api/* stays JSON
ErrorPagesSecuredTest.php the 401 page for an anonymous browser; the 403 page for bob after a real sign-in
LoginFlowTest.php the framework's form login: the redirect with the saved request, ?error, the saved request honoured, POST /logout, bob's 403, the 401 problem document on an API path
ObservabilityTest.php trace ids on the HTTP traffic page and in /actuator/httpexchanges, the HTTP server timer as a Prometheus histogram, the tracing switch, an ECS log document carrying the request's trace id
DataSurfacesTest.php the db health indicator on by default, the datasource page's data-layer panel, a duplicate key refused with a sentence through the data browser

The fixtures under tests/Browser/Support/ boot the skeleton with a created app's provider set. SecuredBrowserTestCase puts the framework's own form login and two memory users (ada/ROLE_ADMIN, bob/ROLE_USER) in front of it and SignedInBrowserTestCase flips the entry point to the login redirect; tests/BrowserSignInFixtureTest.php drives that fixture through Laravel's test client inside the default gate, so the sign-in every browser flow stands on is proved without Node. Two facts of the in-process server shape the fixtures: every visit() is a new browser context (a fresh cookie jar — one page object per flow), and the process is long-lived (the session Store is forgotten after every request; the tracer initialises each fiber's OpenTelemetry context).

The suite is its own PHPUnit testsuite (browser), kept out of the default unit suite in phpunit.xml.dist — a group exclusion would not do, because the plugin starts Playwright the moment a file under tests/Browser/ is loaded. So composer test and composer check never need Node. To run it:

npm ci
npx playwright install chromium   # once
composer test:browser             # add -- --headed to watch

Screenshots land in tests/Browser/Screenshots/ (git-ignored; CI uploads them as the browser-screenshots artifact). Pass Pest options through Composer with --, e.g. composer test:browser -- --filter=AdminDashboard. A page that fails here is fixed in the package that owns it, with a DOM-level regression test beside the existing ones — the browser scenario is the proof, not the only test.

Documentation

Documentation is held to the same gate as code, by seven Pest tests and a strict site build.

Every code listing names the file it came from. A fenced php block in any Markdown file this repository ships — README.md, CHANGELOG.md, every page under docs/, both manuscripts under book/, and the README.md of each package, of skeleton/ and of samples/lumen/ — must carry one of two HTML comments on the line above it:

<!-- source: packages/web/src/Filter/FilterChainRegistrar.php -->
<!-- illustrative: a controller the reader writes in their own application -->

A source: block is compared verbatim against that file, line for line — one constant indentation offset is allowed (a method excerpted out of its class sits at column 0), and whole lines may be cut with a line that is exactly // … (or # … in a file whose comments start with a hash). An illustrative: block is for code that cannot exist in this repository — an application's own class — and still has to parse, import only framework classes that exist and use only attributes that exist. Every block, marked or not, is additionally checked for the firefly.* keys, php artisan firefly:* commands and composer <script> invocations it names. A listing whose SHAPE is a configuration file — a return [ … ]; every one of whose top-level keys is a real firefly.* section — is flattened back to dotted keys on top of that, so the nested block a package README carries is checked setting by setting although it spells no dotted key anywhere; what that check passes over is a setting below the segment an application chooses for itself (a registration id, a client id), because the owning package reads those out of the array rather than through Config. tests/DocsCodeIsRealTest.php is the test; tests/Support/DocsCodeAudit.php is the engine, and its docblock is the full contract. A red run is fixed by making the document true, never by deleting an assertion or by marking a framework excerpt illustrative:.

DocsCodeAudit::AUDITED is the list of trees the engine walks, but it is not what decides the surface: DocsCodeIsRealTest's audits every Markdown file that ships case asks git ls-files '*.md' what this repository publishes and fails naming every tracked file AUDITED does not reach. So a new page, a new package README or a whole new tree of documentation is audited the day it is committed, and taking an entry out fails the same case rather than quietly shrinking the gate. docs/superpowers/ is the one exclusion, and only because it is git-ignored by policy and ships to nobody.

The English manuscript is inside that contract. book/src is an entry in DocsCodeAudit::AUDITED, every one of its php listings carries a source: or an illustrative: marker, and each source: one is compared line for line against the file it names exactly as a page under docs/ is. Three further rules apply to a book excerpt, because a cut is where a true listing turns useless: a // … may not swallow the declaration it belongs to — leaving a { with nothing above it saying what is being declared — it may not reduce a body to {, // …, }, which prints a method that appears to do no work, and an excerpt that shows a docblock must also show what that docblock documents: its /**, its closing line and at least one line of real code after it, or the page prints a paragraph about a declaration the reader never sees. DocsCodeAudit::verifyExcerpt() refuses all three, and its docblock carries the regressions that motivated each.

book/build/verify_code.py book/src and book/build/verify_code.py book/src-es — the two invocations book/README.md documents — are the lint half, and they have one exemption: a listing carrying a source: marker is not handed to php -l. A verbatim fragment of a real file (one method lifted out of its class, an interface's signatures, a docblock) does not parse on its own, and the only way to make it parse is to add lines the file does not have — which is precisely the untrue listing the marker exists to prevent. Such a listing is held to the comparison instead, and the file it quotes is already analysed by this repository's own PHPStan and Pint runs. illustrative: and unmarked listings are still linted, and they are the ones that need it: no file backs them.

Both manuscripts are now converted: book/src and book/src-es are each named in DocsCodeAudit::AUDITED, every php listing in either tree carries one of the two markers, and --require-provenance exits 0 over both. So a green verify_code.py run over either edition says every unmarked-for-comparison listing parses and that every marked one matches the file it shows.

A new Spanish listing means two edits, not one. A source: marker takes its listing OUT of the php -l half, so a marked file that is not also reached from DocsCodeAudit::AUDITED would be checked by neither half — not linted, not compared. AUDITED names the two directories rather than individual files precisely so a new chapter cannot slip in unaudited; DocsCodeIsRealTest refuses a marker on a page it does not audit.

The two editions must stay level, listing for listing and claim for claim. book/src is where a wave lands first, and tests/DocsProseIsRealTest.php holds the two trees together three ways. It measures the per-chapter size of both: while any pair diverges, README.md and book/README.md must say so beside their "complete in both languages" sentence, and when none does, the warning has to go. It pairs the fenced blocks themselves, so a php listing or a source:-marked excerpt whose bytes differ between the editions is a failure — a Spanish fence may translate a trailing shell or tree comment, which is prose a reader reads, and a page claiming the two match "character for character" has to say that is what it covers. And it holds the two chapter-opening promises to the same set of backticked identifiers, because that paragraph is what a wave forgets when it brings a chapter body level and stops one sentence short — five of them shipped describing the pre-wave chapter, one of them telling a Spanish reader that method security lives only at the CQRS bus. A change to a fact both editions state (a count, a default, a retired claim) belongs in both trees in the same commit: the prose guard walks src-es too, and a correction that reaches one edition only is the bilingual version of the defect this whole gate exists to remove.

Every claim a sentence makes is derived, not typed. tests/DocsProseIsRealTest.php is the other half of the listing guard and the larger one: a wrong listing cannot ship, but a wrong sentence can, and several did. It walks README.md, every page under docs/ and both manuscripts, and holds any paragraph that makes an exhaustive claim against a value read out of the source at test time, never against a number typed into the test. That surface is a subset of the listing guard's, not the same one: the listing guard now covers every Markdown file the repository tracks, and this half does not follow it into CHANGELOG.md, book/README.md, the packages/*/README.md Packagist prints as a package front page, skeleton/README.md or samples/lumen/README.md. A listing on one of those pages is held to the provenance contract; a claim on one of them is held to nothing — so a count, a default or an enumeration worth deriving belongs on a page this guard walks, with the package README linking to it rather than restating it. A count is a claim, and the cheapest one to get wrong, so counts are read as words as well as digits, in English and in Spanish. Under guard today: the expression functions SecurityExpressionEvaluator really dispatches, the actuator inventory and every "404 until exposed" sentence, the exceptions PersistenceExceptionTranslator really builds, the order in which ErrorPageRenderer really reads an Accept header, the stereotype hierarchy PHP really declares, the Composer constraint tables composer/semver really matches, the make:firefly-* tables the generators really back, every firefly:cache figure ManifestCacheWriter really produces (the console line, the pair count, the step count and the artifact count, in both manuscripts), the roster of documentation guards this very section names, the registration default DbHealthIndicator's own condition attribute really declares (a stale default is as dangerous as a stale count, and that one outlived its change in five places), the account these pages give of the book's own gate, pinned to what verify_code.py really lints, the App\Support\CachedTransactionalConfiguration workaround no application needs any more, the status samples/lumen really asserts when a wallet command arrives with no credentials, the header names the masker's own regex really covers, the visibility and the coverage EloquentRepository's read helpers really have, the recording doubles packages/testing really ships (a chapter promised ten beside a directory of eleven, and the missing one was the double six new sections were calling), the @method tags RecordRepository really carries beside every paragraph that counts its derived methods, the route the shipped skeleton really serves beside every curl … localhost:8000/… transcript — a json body printed under a path WelcomeController renders as HTML is a command the reader runs and watches fail — the eight comparison labels DataFilter::operators() really declares wherever a page enumerates the data browser's filters (the dashboard ships no localisation, so a translated label is a label that does not exist), and how far apart the two manuscripts really are wherever a page calls the book complete in both languages, the number of showcases ## Featured Patterns really holds and how many of them carry a listing, and the two surfaces the paragraph above compares — read from the prose walk and from DocsCodeAudit::markdownFiles() at test time, so the day a tree joins one guard and not the other, that paragraph fails rather than a reader. The triggers are deliberately narrow — a page may mention hasRole() or /actuator/env in passing without owing the full enumeration — so a red run here is fixed by correcting the sentence, never by loosening the trigger that caught it.

Diagrams are hand-written SVG. No Mermaid, no PlantUML, no raster: plain <rect>/<line>/<text> on a white rounded panel, font-family="sans-serif", exactly one <title> and one <desc>, marker ids prefixed per file, and no <script>, <image>, @font-face or external reference of any kind — so the file is safe to view straight from GitHub. docs/assets/README.md documents the palette and lists, per diagram, the sources it was drawn from. Each one ships twice and byte-identically, as docs/assets/diagrams/<name>.svg and book/art/figures/<name>.svg; tests/DocsDiagramsTest.php holds the pair identical, holds each file to the rules above, and fails if a diagram is embedded nowhere. tests/BannerAssetTest.php does the same for the banner, the logo, the favicon and the stylesheet, and also checks that mkdocs.yml still names each of them, because the way a brand asset rots is that a theme key is renamed and the file is orphaned with nothing going red.

Build the site locally before you push docs. CI's docs job installs exactly mkdocs-material and runs mkdocs build --strict; no plugin needing a native library (the social plugin's cairo/pango among them) may be added, because that job would stop working. Reproduce it with a throwaway virtualenv — .venv-docs/ is already git-ignored:

python3 -m venv .venv-docs
.venv-docs/bin/pip install mkdocs-material
.venv-docs/bin/mkdocs build --strict

Every link resolves, and every number a page quotes is counted, not typed. tests/ReadmeDocLinksTest.php walks every relative Markdown link in README.md and fails if one points at a file that is not there — a renamed document is otherwise a 404 nobody meets until a reader does. tests/SiteNavigationTest.php does the same job for the site and three more besides: every tab in mkdocs.yml is followed the way Material follows it (a section's tab opens its FIRST child, descending while that child is itself a section) and required to land on a document that exists; navigation.indexes is required to be listed exactly when a section really has an index.md/README.md for it to bind, because the feature is inert otherwise and the first explanation of the tab row was wrong in precisely that way; the package and guide counts quoted in prose — on README.md, docs/README.md, docs/index.md and docs/modules.md — are compared against glob() over packages/* and docs/modules/*.md rather than proof-read, so a fortieth guide fails the build instead of turning four sentences into lies no diff touched; and the two wiring idioms docs/modules.md names are held to the split the packages really have, exemplar files included.

tests/ModuleDocumentationTest.php closes the last loop: every docs/modules/*.md must appear in mkdocs.yml's navigation, in the root README.md's module table, in docs/README.md's, in docs/index.md and on docs/modules.md, and every docs/modules/<name>.md a package README promises must exist.

Architecture rules

Package boundaries are enforced with Deptrac (deptrac.yaml): every package is its own layer, and the ruleset section declares which layers each one may depend on. A dependency edge that isn't declared is a Deptrac violation — this is how the monorepo keeps, for example, firefly/kernel free of any dependency on firefly/web. The bundled installer may read the kernel version but has no other framework layer dependency.

Packages from an already-shipped milestone are treated as FROZEN: once a package's milestone is done and reviewed, further edits to its src/ are the exception, not the rule, and are called out explicitly in commit messages and code comments when they do happen (e.g. // It modifies NOTHING in firefly/context (frozen 26.07.4) in packages/autoconfigure/src/FireflyAutoConfigureServiceProvider.php). If your change needs to touch a frozen package, say so explicitly in the PR description and be prepared to justify why a non-frozen seam (a new hook point, a new package) couldn't do the job instead.

Conventions

  • Style: Laravel Pint (composer pint/pint-test). pint.json sets the laravel preset and three rules on top of it: declare_strict_types (every file opens with declare(strict_types=1);), final_class: false (a class is final when its author means it, not by fiat — firefly:cache generates proxies that extend a repository), and alpha-sorted imports. skeleton/ is excluded.
  • Static analysis: PHPStan at max-equivalent strictness (composer stan).
  • TDD: tests are written alongside (generally before) implementation — see the shipped test suites under every packages/*/tests/ for the house style; firefly/testing is the shared harness every package's own tests are built on.
  • Versioning: CalVer (YY.MM.Patch) — see Versioning. No package's composer.json carries a version field.

Never commit

The following must never be tracked in git — enforced both by the local core.hooksPath pre-push hook (once configured above) and by CI running the same guard on every push:

  • .superpowers/ and docs/superpowers/ — internal design docs/specs/plans, git-ignored and excluded from the built docs site.
  • .claude (file or directory).
  • .env / .env.* (.env.example is explicitly allowed).
  • Private key material (id_rsa, id_ed25519, *.pem, *.p12, *.pfx) or a literal secret marker (a PEM private-key header, an AWS access key ID pattern) in any tracked file's content.

scripts/check-no-sensitive-tracked.sh is the single source of truth for these rules — read it directly if you need the exact patterns rather than relying on this list going stale.