Skip to content

feat: add living specifications system - #835

Open
edwh wants to merge 13 commits into
developfrom
feature/living-specs
Open

edwh wants to merge 13 commits into
developfrom
feature/living-specs

Conversation

@edwh

@edwh edwh commented Apr 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a living documentation system that embeds user stories in PHP controller code using PHP 8 attributes (#[Feature], #[UserStory], #[NoStory]).
  • php artisan specs:extract parses the code with nikic/php-parser and writes docs/specs/manifest.json: 188 stories across 8 features and 7 personas (Admin, Host, Restarter, NetworkCoordinator, Guest, ThirdParty, RepairDirectoryAdmin). Stories are grouped by theme within each feature.
  • Tests link to stories with @story: docblock references (168 of 188 stories have at least one linked test). A reference can use the short class name (GroupController::stats) only when exactly one controller declares that method; otherwise it must use the fully-qualified class name, so the web and API\ controllers of the same name cannot be confused.
  • A VitePress site in specs-site/ is built from the manifest (browse by feature or by persona, with narratives, coverage markers and source links) and deployed to GitHub Pages from develop by a GitHub Actions workflow.
  • Removes the old features/ directory of Gherkin files; the useful content now lives in the annotations.

Live preview: https://therestartproject.github.io/restarters.net/

What the check enforces

php artisan specs:extract --check exits non-zero if any of these is true:

  • a public method in app/Http/Controllers has neither #[UserStory] nor #[NoStory];
  • a @story: reference points at a method that does not exist, or is ambiguous between two controllers;
  • docs/specs/manifest.json differs from what the code now generates.

It runs in CircleCI as its own early step ("Check living specs", before PHPUnit) and as tests/Unit/SpecsExtractTest.php.

Persona notes

  • The CSV export routes in ExportController are public (therestartproject.org/download-dataset uses them), so their stories are written for Guest and ThirdParty.
  • The Repair Directory role story uses its own RepairDirectoryAdmin persona (Repair Directory SuperAdmin or RegionalAdmin), distinct from the platform Administrator.
  • The online-event story lives only on API\EventController::createEventv2; PartyController::create only has the "open the create form" story.
  • CalendarEventsController::allEvents has a ThirdParty story like the other iCal feeds.
  • Admin-only tools with no user-facing story (StyleController, MapsProxyController) are #[NoStory]; PreviewDeployController, the network tag endpoints, API\GroupController::listSummaryv2 and the ORDS repair export (API\RepairController) have stories based on their real permission checks.

Test plan

  • php artisan specs:extract generates the manifest (188 stories) and --check passes on this branch
  • --check reports unannotated methods and unresolvable @story: references (verified with a deliberately bad reference)
  • node generate-pages.mjs in specs-site/ generates pages from the new manifest
  • Merged with current develop, conflicts resolved keeping develop's behaviour
  • Full PHPUnit suite and CircleCI pass (attributes are inert metadata; CI is the gate)
  • Full npm run build of the VitePress site (only page generation checked after the latest changes)

Comment thread .github/workflows/specs-site.yml Fixed
Comment thread .github/workflows/specs-site.yml Fixed
Comment thread .github/workflows/specs-site.yml Fixed
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
1 Security Hotspot
C Security Rating on New Code (required ≥ A)
D Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

edwh and others added 4 commits July 15, 2026 09:57
Introduces a code-first living documentation system that embeds user
stories directly in controller code via PHP 8 attributes (#[Feature],
#[UserStory], #[NoStory]), extracts them into a JSON manifest, and
builds a browsable VitePress static site with dual navigation by
feature and by persona.

- 3 PHP attribute classes in app/Attributes/
- specs:extract artisan command using nikic/php-parser AST analysis
  (already a transitive composer dependency, no new requirement)
- JSON manifest at docs/specs/manifest.json, 8 narrative markdown
  files in docs/specs/narratives/
- Standalone VitePress site in specs-site/ with prebuild page
  generation (independent package.json, does not touch the app's
  Vite/npm setup)
- GitHub Actions workflow for GitHub Pages deployment
- Removes the historical features/ directory (Gherkin-style specs),
  superseded by the annotation system; untouched since this branch
  first diverged from develop so nothing current is lost
- Adds a Living Specifications section to CLAUDE.md describing the
  workflow for keeping annotations current

Rebuilt from the original feature/living-specs branch (~600 commits
behind develop) on top of current develop. The @story annotations on
controllers and tests are ported in follow-up commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit's message described this addition but the section
was left out of what got staged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the user story attributes from the original feature/living-specs
branch onto the current controller code. Annotated via #[Feature] on
each controller class and #[UserStory]/#[NoStory] on public action
methods, matched by method name against the branch's final annotated
state and merged with a 3-way text merge (base = branch's original
divergence point, mine = current develop, theirs = branch tip) so
that unrelated evolution of these files (return-type hints added since)
is preserved alongside the new attributes.

168 user stories across 8 features and 6 personas, grouped into
themes within each feature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports @story:ClassName::method docblock references from the original
feature/living-specs branch onto the current test suite, linking test
methods to the user stories added in the previous commit. specs:extract
picks these up via regex over docblocks so the VitePress site can show
per-story test coverage.

Merged the same way as the controller annotations: a 3-way text merge
per file (branch divergence point / current develop / branch tip),
which correctly threads new docblock lines through method signatures
that have since gained return-type hints, without disturbing them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@edwh
edwh force-pushed the feature/living-specs branch from fc617f1 to a0299fd Compare July 15, 2026 09:09
@edwh

edwh commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Rebuilt this branch on current develop rather than rebasing — the original was ~600 commits behind and the @story docblock diffs no longer applied after the Vite migration and general drift.

What changed:

  • Recreated the branch from current develop as 4 commits (system + docs, controller attributes, test annotations, one follow-up fixing a CLAUDE.md addition that got left out of the first pass).
  • Carried over the standalone deliverables as-is: app/Attributes/{Feature,NoStory,UserStory}.php, app/Console/Commands/SpecsExtract.php, docs/specs/manifest.json + narratives, the standalone VitePress site in specs-site/ (own package.json, doesn't touch the app's Vite/npm setup), the GitHub Pages workflow, and the design doc. Also re-applied the deletion of the legacy features/ Gherkin directory — it hadn't been touched since this branch first diverged, so nothing since has been lost by removing it.
  • Ported every #[Feature]/#[UserStory]/#[NoStory] attribute and every @story: test reference from the original branch's final state onto the current code, using a per-file 3-way text merge (merge-base version / current develop / branch tip). Where current develop had independently added PHP return-type hints on the same method signatures the branch was annotating, the merge preserves both.
  • Verified nothing was dropped: counted attributes/references before and after — 168/168 #[UserStory], 31/31 #[Feature], 15/15 #[NoStory], and 326/326 @story: test references all made it across untouched files or files that still exist with the same method names. No controller or test file referenced by the original branch had been deleted or renamed, so there was nothing to drop or note as stale.
  • Validated with php -l on all 94 touched PHP files (via the running restarters container) and ran the VitePress prebuild script locally against the carried-over manifest — both succeed.

Needs human follow-up:

  • docs/specs/manifest.json is carried over verbatim from the original branch's last generation and wasn't regenerated, since php artisan specs:extract needs a full app container this worktree doesn't have. Given the annotation counts match exactly, it should already be accurate, but it's worth running php artisan specs:extract once after merge (or as part of a follow-up) to confirm and refresh the generatedAt timestamp.
  • Did not run the PHPUnit suite (shared test DB, not safe to run from a second checkout) or the specs:extract command itself — only static validation (lint + counts + the JS prebuild step) was possible here.

Comment thread .github/workflows/specs-site.yml Fixed
Comment thread .github/workflows/specs-site.yml Fixed
Comment thread .github/workflows/specs-site.yml Fixed
edwh and others added 3 commits July 15, 2026 10:16
SonarCloud (rightly) wants write permissions scoped to the deploy job
rather than granted workflow-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
- generate-pages.mjs: give every string sort an explicit localeCompare
  comparator (default sort is lexicographic-by-code-unit)
- SpecsExtract: coalesce php-parser dynamic-property iterables to [] -
  the analyzer cannot see them initialized, and a malformed node would
  genuinely make them so

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
Sonar's PHP analyzer cannot see php-parser's vendor property
declarations, so it treats every AST property read as uninitialized
even behind a null-coalesce. Assign to explicit locals and mark the
three reads NOSONAR with justification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TzY1phBjyT3HogpeS53zon
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

@edwh edwh changed the title Add living specifications system feat: add living specifications system Sep 7, 2026
@ngm

ngm commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

@edwh I can't recall who needed to action this one?

@edwh

edwh commented Sep 23, 2026

Copy link
Copy Markdown
Collaborator Author

@ngm — this one is mine to action. Below is the review of the outstanding test-plan item ("review a sample of controller annotations for accuracy"). There are a few fixes to make and then develop to merge in (the branch is 68 commits behind). After that it needs a reviewer.

(AI-assisted review by Claude, run at a maintainer's request. Each finding below was checked against the code.)

Sample: 35 #[UserStory] attributes across events, groups, users, devices, networks, admin, exports, roles, skills and tags; about 15 @story: test links; all 15 #[NoStory] uses. Most stories are accurate and match the actual permission checks. The test links are solid: no mislinks found.

Wrong or misleading stories

  • ExportController — all five stories (devicesEvent, devicesGroup, devices, groupEvents, networkEvents) say Restarter or NetworkCoordinator. None of these methods checks permissions, and routes/web.php says outright that the export routes allow anonymous access because therestartproject.org/download-dataset calls them. The persona should be Guest or ThirdParty. As written, the spec suggests these exports are restricted when they're public.
  • PartyController::create has a second story, "As a Host, I can create an online event without a physical location". create() only renders the blank form. The online flag is handled in API\EventController::createEventv2, which already has its own correct story, and OnlineEventsTest covers it. The extra story should go.

Minor

  • UserController Repair Directory role story is labelled Admin. The check is repairdir_role() (Repair Directory SuperAdmin/RegionalAdmin), not the platform Administrator role behind every other Admin story, so the label is ambiguous.
  • CalendarEventsController::allEvents is #[NoStory], but its four siblings, which are also iCal feeds protected by a secret token, all have stories. Either all of them are user-facing or none are.
  • Some methods that Admin, Host and NetworkCoordinator can all use have a story for only one of those personas (e.g. GroupController::edit). That's understated rather than wrong.

Checked and correct

  • API\DeviceController::createDevicev2 / updateDevicev2 say "a Restarter can log a device at an event I attended". That's right: userHasEditEventsDevicesPermission allows confirmed attendees. The inline comment there ("Only hosts can add devices to events") is stale and misleading, and could be fixed while we're in there.

Process gaps

  • The description overstates --check. It says --check detects unannotated public methods. It doesn't: checkManifest() only compares a freshly generated manifest with the committed one. Nothing in CI runs it either. So new endpoints can land with no annotation and nothing flags them. API\GroupController::listSummaryv2, added on develop since this branch point, is already a case. Either add a real "every public controller method has #[UserStory] or #[NoStory]" check and wire it into CI, or narrow the description.
  • Latent mislink risk: SpecsExtract indexes @story: references by unqualified ShortClass::method. GroupController, NetworkController, DeviceController and UserController each exist in both the web and API\ namespaces. There's no collision today, but a same-named method in both would mislink silently.

Non-annotation checks (fine)

  • The attributes are inert DTOs, and the only consumer is the artisan command, which parses the code statically rather than using reflection at request time. So there's no runtime behaviour change.
  • The Sonar workflow-permissions alerts are already fixed on the branch (job-level permissions:).
  • Removing features/ leaves no dangling references.

To merge: fix the export personas, drop the extra create() story, merge develop and annotate what came with it, and settle the --check wording or enforcement.

edwh and others added 4 commits October 1, 2026 17:47
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…iguous @story refs

Index @story: references by fully-qualified class; short names are only
accepted when exactly one controller declares the method. Run the check in
CircleCI and as a unit test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Exports are public (Guest/ThirdParty), drop the online-event story from
PartyController::create, make the Repair Directory persona explicit, give
allEvents a calendar story, fix stale device comments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@edwh

edwh commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Actioned the review. Changes by point (all pushed to this branch):

  • Merge develop: merged origin/develop; one conflict in GroupController, resolved by taking develop's code (its removal of expandGroups) and keeping our annotations.
  • ExportController: all five export stories were Restarter/NetworkCoordinator; they are now Guest and ThirdParty stories, matching the public routes.
  • PartyController::create: removed the extra online-event story; it stays on API\EventController::createEventv2.
  • Minor: the Repair Directory role story now uses a distinct RepairDirectoryAdmin persona (was "Admin"); CalendarEventsController::allEvents has a ThirdParty iCal story (was #[NoStory]); the stale "Only hosts can..." comments in API\DeviceController (add, edit, delete) now say hosts, network coordinators, admins and confirmed attendees.
  • --check enforcement: before, it only compared the manifest to a fresh one. Now it also fails when a public controller method has neither #[UserStory] nor #[NoStory], or a @story: reference is unresolvable/ambiguous. It runs in CircleCI as a "Check living specs" step ahead of PHPUnit, and as tests/Unit/SpecsExtractTest.php. The first run flagged 14 methods, now annotated from their real permission checks: API\GroupController::listSummaryv2 (public), API\NetworkController tags/stats (public reads; create/update/delete for NetworkCoordinator of that network or Admin), API\RepairController::listRepairsv2 (Admin, ORDS export), PreviewDeployController (Admin), and StyleController/MapsProxyController as #[NoStory].
  • Mislink risk: @story: references are now indexed by fully-qualified class. Short::method still works but only if one controller declares that method; otherwise --check fails and asks for the full class name. All existing refs resolve unchanged. CLAUDE.md documents this.
  • Manifest regenerated (188 stories, 7 personas) and --check passes. Page generation for the specs site works; I did not run the full VitePress build.

Not verified locally: PHPUnit (the container mounts the main checkout, so it cannot see this worktree) - CircleCI is the check for that. The PR description is rewritten to describe the end state, with the --check claim corrected.


- name: Install dependencies
working-directory: specs-site
run: npm ci
… independent

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Oct 1, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants