Compare commits

..

No commits in common. "ff1bea92c540dc4f66b2f6362911080819df9791" and "104909bcf507a183d2d44dc88049c74721f90f28" have entirely different histories.

22 changed files with 14 additions and 864 deletions

View file

@ -15,14 +15,11 @@ intentionally omitted here - update this section as entities land.
- Look at similar entities for reference before writing anything new
- Entities: constructor with properties, getters
- DTOs: simple data containers for creation (e.g. `CreateElementDto`)
- DTOs: simple data containers for creation (e.g. `CreateSetDto`)
- Repositories: interfaces that define data access
- Do not write unit tests for concrete repository implementations
(e.g. `Postgres*Repository`). They are exercised by e2e tests.
Use cases are tested with fake repositories.
- Repository methods that find records by a foreign key should accept
the related entity, not a raw id (e.g. `findBySet(Set $set)`, not
`findBySetId(int $setId)`).
- Use cases: business logic with Request objects
- When throwing exceptions, add `@throws` docblock
- Fakes: in-memory implementations for testing
@ -53,33 +50,16 @@ to be added to the repo; the Nix flake already provides
Constructs LLMs default to that this project forbids. Name the trap
explicitly so you catch yourself before writing it.
- Arrow function:
- Forbidden: `fn ($node) => $node->getId()`
- Required: `function ($node) { return $node->getId(); }`
- Inline FQCN type:
- Forbidden: `function f(): \Psr\Http\Message\ResponseInterface`
- Required: import `ResponseInterface`, then return `ResponseInterface`
- Inline `::class`:
- Forbidden: `Container::get(\App\Foo\Bar::class)`
- Required: import `Bar`, then call `Container::get(Bar::class)`
- Default param:
- Forbidden: `function f(int $count = 10)`
- Required: `function f(int $count)`
- Default in fake:
- Forbidden: `public function create(Dto $dto, bool $strict = true)`
- Required: no default; every caller passes a value
- Lookup returns stored ref:
- Forbidden: `return $this->items[$id] ?? null;`
- Required: rebuild a new instance with the stored fields
- FK lookup by id:
- Forbidden: `findBySetId(int $setId)`
- Required: `findBySet(Set $set)`
- Short variable name:
- Forbidden: one-letter or abbreviated names (`$t`, `$n`, `$res`)
- Required: explicit names (`$text`, `$node`, `$response`)
- Em dash:
- Forbidden: comment or docblock containing an em dash character
- Required: use `-`
| Anti-pattern | Forbidden | Required |
|---|---|---|
| Arrow function | `fn ($x) => $x->getId()` | `function ($x) { return $x->getId(); }` |
| Inline FQCN type | `function f(): \Psr\Http\Message\ResponseInterface` | `use Psr\Http\Message\ResponseInterface;` then `function f(): ResponseInterface` |
| Inline `::class` | `Container::get(\App\Foo\Bar::class)` | `use App\Foo\Bar;` then `Container::get(Bar::class)` |
| Default param | `function f(int $count = 10)` | `function f(int $count)` |
| Default in fake | `public function create(Dto $dto, bool $strict = true)` | no default; every caller passes a value |
| Lookup returns stored ref | `return $this->items[$id] ?? null;` | rebuild a new instance with the stored fields |
| Short variable name | `$t`, `$n`, `$res`, `$req`, `$e` | `$text`, `$node`, `$response`, `$request`, `$exception` |
| Em dash | `// fetches user — by email` | `// fetches user - by email` |
When generating code, scan the diff for these patterns before writing it
to disk. If you catch one mid-write, rewrite that line.

View file

@ -16,9 +16,7 @@ guides (`backend-context.md`, `frontend-context.md`) extend these.
4. Implement the code to make the test pass
5. Run the test to confirm it passes
6. Commit the implementation
7. Repeat this red/green commit cycle for each new behavior. Do not
batch multiple behaviors into one failing-test commit and one
implementation commit when they can be reviewed separately.
7. Repeat for each new behavior
## Code style
@ -34,8 +32,8 @@ guides (`backend-context.md`, `frontend-context.md`) extend these.
and fakes - if a helper accepts a value, every caller must supply it.
- First, explore the codebase to understand existing patterns - look at similar
files for reference before writing anything
- Never use em dash characters in code, comments, or docblocks - use
hyphens (-) instead
- Never use em dashes (—) in code, comments, or docblocks - use hyphens (-)
instead
## Git commit style
@ -53,21 +51,9 @@ guides (`backend-context.md`, `frontend-context.md`) extend these.
## Git commits
- Tests should be committed first, before implementation
- Prefer small, reviewable commits. Commit each meaningful step as soon as
it is green and the pre-commit checklist passes.
- One logical change per commit - a commit may span multiple files when they
form a single logical unit (e.g. a use case with its request and exception,
or a Vue SFC with its Pinia store and route entry)
- Split commits by behavior, public contract, migration, wiring, docs, or
mechanical formatting when those parts can be reviewed independently.
- A multi-file commit is okay only when the files form one inseparable
change. Do not use "one logical change" to justify batching adjacent work.
- Do not batch several behaviors into one "tests" commit and one
"implementation" commit. Repeat the red/green cycle per behavior.
- Do not bundle backend and frontend changes unless both are required for
the same user-facing behavior.
- Do not bundle cleanup, refactors, renames, or formatting with feature
behavior.
- Keep commits focused: not one file per commit, not unrelated work batched
- Make commits frequent - commit each meaningful logical step as you go
- Commits are for reviewing and documenting the development of code
@ -97,8 +83,6 @@ mechanical, not aspirational - a "yes" to all is required.
- [ ] On a feature branch (not master/main).
- [ ] This commit is one logical change. If it spans unrelated changes,
stop and split it.
- [ ] This commit has been split as far as it can be while staying
reviewable. If any part can land independently, split it.
- [ ] Tests for new behavior were committed BEFORE this implementation
(or this commit IS the failing-test commit).
@ -111,8 +95,6 @@ mechanical, not aspirational - a "yes" to all is required.
(PHP or TS).
- [ ] Find/lookup repository methods return new instances, not stored
references.
- [ ] Backend repository methods that find by a foreign key accept the
related entity, not a raw id.
- [ ] No em dashes (use hyphens).
- [ ] Variable names are explicit (no `$t`, `n`, `res`, `req`, `e`, etc.).
- [ ] No `any` in TypeScript - use concrete types or `unknown` with a

View file

@ -1,14 +0,0 @@
<?php
namespace App\Element;
use App\Set\Set;
class CreateElementDto
{
public function __construct(
public Set $set,
public string $title,
public ?Element $parentElement,
) {}
}

View file

@ -1,35 +0,0 @@
<?php
namespace App\Element;
use App\Set\Set;
class Element
{
public function __construct(
private int $id,
private string $title,
private Set $set,
private ?Element $parentElement,
) {}
public function getId(): int
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function getSet(): Set
{
return $this->set;
}
public function getParentElement(): ?Element
{
return $this->parentElement;
}
}

View file

@ -1,40 +0,0 @@
<?php
namespace App\Element;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property int $set_id
* @property string $title
* @property int|null $parent_element_id
*
* @method static Builder<static>|ElementModel newModelQuery()
* @method static Builder<static>|ElementModel newQuery()
* @method static Builder<static>|ElementModel query()
* @method static Builder<static>|ElementModel whereId($value)
* @method static Builder<static>|ElementModel whereParentElementId($value)
* @method static Builder<static>|ElementModel whereSetId($value)
* @method static Builder<static>|ElementModel whereTitle($value)
*
* @mixin \Eloquent
*/
class ElementModel extends Model
{
protected $table = 'elements';
public $timestamps = false;
protected $fillable = [
'set_id',
'title',
'parent_element_id',
];
protected $casts = [
'set_id' => 'integer',
'parent_element_id' => 'integer',
];
}

View file

@ -1,17 +0,0 @@
<?php
namespace App\Element;
use App\Set\Set as DomainSet;
interface ElementRepository
{
public function create(CreateElementDto $dto): Element;
public function find(int $id): ?Element;
/**
* @return Element[]
*/
public function findBySet(DomainSet $set): array;
}

View file

@ -1,78 +0,0 @@
<?php
namespace App\Element;
use App\Set\Set as DomainSet;
use App\Set\SetRepository;
use DomainException;
class EloquentElementRepository implements ElementRepository
{
public function __construct(private SetRepository $setRepo) {}
public function create(CreateElementDto $dto): Element
{
$model = ElementModel::create([
'set_id' => $dto->set->getId(),
'title' => $dto->title,
'parent_element_id' => $dto->parentElement?->getId(),
]);
return new Element(
id: $model->id,
title: $dto->title,
set: $dto->set,
parentElement: $dto->parentElement,
);
}
public function find(int $id): ?Element
{
$model = ElementModel::find($id);
return $model === null ? null : $this->toDomain($model);
}
/**
* @return Element[]
*/
public function findBySet(DomainSet $set): array
{
$models = ElementModel::where('set_id', $set->getId())
->orderBy('id')
->get();
$elements = [];
foreach ($models as $model) {
$elements[] = $this->toDomain($model);
}
return $elements;
}
private function toDomain(ElementModel $model): Element
{
$set = $this->setRepo->find($model->set_id);
if ($set === null) {
throw new DomainException(
"Set with id: {$model->set_id} doesnt exist"
);
}
$parentElement = null;
if ($model->parent_element_id !== null) {
$parentElement = $this->find($model->parent_element_id);
if ($parentElement === null) {
throw new DomainException(
"Element with id: {$model->parent_element_id} doesnt exist"
);
}
}
return new Element(
id: $model->id,
title: $model->title,
set: $set,
parentElement: $parentElement,
);
}
}

View file

@ -1,85 +0,0 @@
<?php
namespace App\Element\UseCases\CreateElement;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Set\Set as DomainSet;
use App\Set\SetRepository;
use DomainException;
class CreateElement
{
public function __construct(
private ElementRepository $elementRepo,
private SetRepository $setRepo,
) {}
/**
* @throws BadRequestException
* @throws DomainException
*/
public function execute(CreateElementRequest $request): Element
{
if ($request->setId === null) {
throw new BadRequestException('setId is required');
}
if ($request->title === null || $request->title === '') {
throw new BadRequestException('title is required');
}
$set = $this->setRepo->find($request->setId);
if ($set === null) {
throw new DomainException(
"Set with id: {$request->setId} doesnt exist"
);
}
if ($request->parentElementId === null) {
$this->validateNoRootElementExists($set);
return $this->elementRepo->create(new CreateElementDto(
set: $set,
title: $request->title,
parentElement: null,
));
}
$parentElement = $this->elementRepo->find(
$request->parentElementId
);
if ($parentElement === null) {
throw new DomainException(
"Element with id: {$request->parentElementId} doesnt exist"
);
}
if ($parentElement->getSet()->getId() !== $set->getId()) {
throw new DomainException(
'Parent element must belong to the same set'
);
}
return $this->elementRepo->create(new CreateElementDto(
set: $set,
title: $request->title,
parentElement: $parentElement,
));
}
/**
* @throws DomainException
*/
private function validateNoRootElementExists(DomainSet $set): void
{
$elements = $this->elementRepo->findBySet($set);
foreach ($elements as $element) {
if ($element->getParentElement() === null) {
throw new DomainException(
'A root element already exists for this set'
);
}
}
}
}

View file

@ -1,12 +0,0 @@
<?php
namespace App\Element\UseCases\CreateElement;
class CreateElementRequest
{
public function __construct(
public ?int $setId,
public ?string $title,
public ?int $parentElementId,
) {}
}

View file

@ -4,10 +4,6 @@ namespace App\Providers;
use App\Auth\EloquentSessionRepository;
use App\Auth\SessionRepository;
use App\Element\ElementRepository;
use App\Element\EloquentElementRepository;
use App\Set\EloquentSetRepository;
use App\Set\SetRepository;
use App\User\EloquentUserRepository;
use App\User\UserRepository;
use Illuminate\Support\ServiceProvider;
@ -24,13 +20,5 @@ class RepositoryServiceProvider extends ServiceProvider
SessionRepository::class,
EloquentSessionRepository::class
);
$this->app->bind(
SetRepository::class,
EloquentSetRepository::class
);
$this->app->bind(
ElementRepository::class,
EloquentElementRepository::class
);
}
}

View file

@ -1,10 +0,0 @@
<?php
namespace App\Set;
class CreateSetDto
{
public function __construct(
public string $name,
) {}
}

View file

@ -1,41 +0,0 @@
<?php
namespace App\Set;
class EloquentSetRepository implements SetRepository
{
public function create(CreateSetDto $dto): Set
{
$model = SetModel::create([
'name' => $dto->name,
]);
return $this->toDomain($model);
}
public function find(int $id): ?Set
{
$model = SetModel::find($id);
return $model === null ? null : $this->toDomain($model);
}
public function getAll(): array
{
$models = SetModel::orderBy('id')->get();
$sets = [];
foreach ($models as $model) {
$sets[] = $this->toDomain($model);
}
return $sets;
}
private function toDomain(SetModel $model): Set
{
return new Set(
id: $model->id,
name: $model->name,
);
}
}

View file

@ -1,21 +0,0 @@
<?php
namespace App\Set;
class Set
{
public function __construct(
private int $id,
private string $name,
) {}
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace App\Set;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name
*
* @method static Builder<static>|SetModel newModelQuery()
* @method static Builder<static>|SetModel newQuery()
* @method static Builder<static>|SetModel query()
* @method static Builder<static>|SetModel whereId($value)
* @method static Builder<static>|SetModel whereName($value)
*
* @mixin \Eloquent
*/
class SetModel extends Model
{
protected $table = 'sets';
public $timestamps = false;
protected $fillable = ['name'];
}

View file

@ -1,15 +0,0 @@
<?php
namespace App\Set;
interface SetRepository
{
public function create(CreateSetDto $dto): Set;
public function find(int $id): ?Set;
/**
* @return Set[]
*/
public function getAll(): array;
}

View file

@ -1,21 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('sets', function (Blueprint $table) {
$table->id();
$table->string('name');
});
}
public function down(): void
{
Schema::dropIfExists('sets');
}
};

View file

@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('elements', function (Blueprint $table) {
$table->id();
$table->foreignId('set_id')->constrained('sets');
$table->string('title');
$table->foreignId('parent_element_id')
->nullable()
->constrained('elements');
});
}
public function down(): void
{
Schema::dropIfExists('elements');
}
};

View file

@ -1,69 +0,0 @@
<?php
namespace Tests\Fakes;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Set\Set as DomainSet;
class FakeElementRepository implements ElementRepository
{
/**
* @var Element[]
*/
private array $elementsById = [];
public function create(CreateElementDto $dto): Element
{
$id = count($this->elementsById) + 1;
$element = new Element(
id: $id,
title: $dto->title,
set: $dto->set,
parentElement: $dto->parentElement,
);
$this->elementsById[$id] = $element;
return $element;
}
public function find(int $id): ?Element
{
if (! isset($this->elementsById[$id])) {
return null;
}
return $this->cloneElement($this->elementsById[$id]);
}
/**
* @return Element[]
*/
public function findBySet(DomainSet $set): array
{
$elements = [];
foreach ($this->elementsById as $element) {
if ($element->getSet()->getId() === $set->getId()) {
$elements[] = $this->cloneElement($element);
}
}
return $elements;
}
private function cloneElement(Element $element): Element
{
$parentElement = $element->getParentElement();
if ($parentElement !== null) {
$parentElement = $this->cloneElement($parentElement);
}
return new Element(
id: $element->getId(),
title: $element->getTitle(),
set: $element->getSet(),
parentElement: $parentElement,
);
}
}

View file

@ -1,57 +0,0 @@
<?php
namespace Tests\Fakes;
use App\Set\CreateSetDto;
use App\Set\Set as DomainSet;
use App\Set\SetRepository;
class FakeSetRepository implements SetRepository
{
/**
* @var DomainSet[]
*/
private array $setsById = [];
public function create(CreateSetDto $dto): DomainSet
{
$id = count($this->setsById) + 1;
$set = new DomainSet(
id: $id,
name: $dto->name,
);
$this->setsById[$id] = $set;
return $set;
}
public function find(int $id): ?DomainSet
{
if (! isset($this->setsById[$id])) {
return null;
}
return $this->cloneSet($this->setsById[$id]);
}
/**
* @return DomainSet[]
*/
public function getAll(): array
{
$sets = [];
foreach ($this->setsById as $set) {
$sets[] = $this->cloneSet($set);
}
return $sets;
}
private function cloneSet(DomainSet $set): DomainSet
{
return new DomainSet(
id: $set->getId(),
name: $set->getName(),
);
}
}

View file

@ -1,33 +0,0 @@
<?php
namespace Tests\Unit\Element;
use App\Element\Element;
use App\Set\Set as DomainSet;
use Tests\TestCase;
class ElementTest extends TestCase
{
public function testCreatesElementWithNullableParent(): void
{
$set = new DomainSet(1, 'Daily learning');
$rootElement = new Element(
id: 1,
title: 'Root',
set: $set,
parentElement: null,
);
$childElement = new Element(
id: 2,
title: 'Child',
set: $set,
parentElement: $rootElement,
);
$this->assertSame(2, $childElement->getId());
$this->assertSame('Child', $childElement->getTitle());
$this->assertSame($set, $childElement->getSet());
$this->assertSame($rootElement, $childElement->getParentElement());
$this->assertNull($rootElement->getParentElement());
}
}

View file

@ -1,183 +0,0 @@
<?php
namespace Tests\Unit\Element\UseCases;
use App\Element\Element;
use App\Element\UseCases\CreateElement\CreateElement;
use App\Element\UseCases\CreateElement\CreateElementRequest;
use App\Exceptions\BadRequestException;
use App\Set\CreateSetDto;
use DomainException;
use Tests\Fakes\FakeElementRepository;
use Tests\Fakes\FakeSetRepository;
use Tests\TestCase;
class CreateElementTest extends TestCase
{
private FakeSetRepository $setRepo;
private FakeElementRepository $elementRepo;
private CreateElement $createElement;
protected function setUp(): void
{
$this->setRepo = new FakeSetRepository();
$this->elementRepo = new FakeElementRepository();
$this->createElement = new CreateElement(
$this->elementRepo,
$this->setRepo,
);
}
public function testCreatesRootElement(): void
{
$set = $this->setRepo->create(
new CreateSetDto('Daily learning')
);
$element = $this->createElement->execute(new CreateElementRequest(
setId: $set->getId(),
title: 'Root',
parentElementId: null,
));
$this->assertInstanceOf(Element::class, $element);
$this->assertSame('Root', $element->getTitle());
$this->assertSame($set->getId(), $element->getSet()->getId());
$this->assertNull($element->getParentElement());
}
public function testCreatesChildElement(): void
{
$set = $this->setRepo->create(
new CreateSetDto('Daily learning')
);
$rootElement = $this->createElement->execute(
new CreateElementRequest(
setId: $set->getId(),
title: 'Root',
parentElementId: null,
)
);
$childElement = $this->createElement->execute(
new CreateElementRequest(
setId: $set->getId(),
title: 'Child',
parentElementId: $rootElement->getId(),
)
);
$this->assertSame('Child', $childElement->getTitle());
$this->assertSame(
$rootElement->getId(),
$childElement->getParentElement()->getId(),
);
}
public function testThrowsWhenSetIdMissing(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('setId is required');
$this->createElement->execute(new CreateElementRequest(
setId: null,
title: 'Root',
parentElementId: null,
));
}
public function testThrowsWhenTitleMissing(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('title is required');
$this->createElement->execute(new CreateElementRequest(
setId: 1,
title: null,
parentElementId: null,
));
}
public function testThrowsWhenSetDoesNotExist(): void
{
$this->expectException(DomainException::class);
$this->expectExceptionMessage('Set with id: 99 doesnt exist');
$this->createElement->execute(new CreateElementRequest(
setId: 99,
title: 'Root',
parentElementId: null,
));
}
public function testThrowsWhenParentElementDoesNotExist(): void
{
$set = $this->setRepo->create(
new CreateSetDto('Daily learning')
);
$this->expectException(DomainException::class);
$this->expectExceptionMessage(
'Element with id: 99 doesnt exist'
);
$this->createElement->execute(new CreateElementRequest(
setId: $set->getId(),
title: 'Child',
parentElementId: 99,
));
}
public function testThrowsWhenRootElementAlreadyExists(): void
{
$set = $this->setRepo->create(
new CreateSetDto('Daily learning')
);
$this->createElement->execute(new CreateElementRequest(
setId: $set->getId(),
title: 'Root',
parentElementId: null,
));
$this->expectException(DomainException::class);
$this->expectExceptionMessage(
'A root element already exists for this set'
);
$this->createElement->execute(new CreateElementRequest(
setId: $set->getId(),
title: 'Another root',
parentElementId: null,
));
}
public function testThrowsWhenParentBelongsToAnotherSet(): void
{
$parentSet = $this->setRepo->create(
new CreateSetDto('Parent set')
);
$childSet = $this->setRepo->create(
new CreateSetDto('Child set')
);
$parentElement = $this->createElement->execute(
new CreateElementRequest(
setId: $parentSet->getId(),
title: 'Parent root',
parentElementId: null,
)
);
$this->expectException(DomainException::class);
$this->expectExceptionMessage(
'Parent element must belong to the same set'
);
$this->createElement->execute(new CreateElementRequest(
setId: $childSet->getId(),
title: 'Invalid child',
parentElementId: $parentElement->getId(),
));
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace Tests\Unit\Set;
use App\Set\Set as DomainSet;
use Tests\TestCase;
class SetTest extends TestCase
{
public function testCreatesSetWithName(): void
{
$set = new DomainSet(1, 'Daily learning');
$this->assertSame(1, $set->getId());
$this->assertSame('Daily learning', $set->getName());
}
}