spec-first-copilot 0.7.0-beta.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +252 -167
- package/bin/cli.js +70 -70
- package/lib/init.js +92 -92
- package/lib/update.js +132 -132
- package/package.json +1 -1
- package/templates/.ai/memory/napkin.md +68 -68
- package/templates/.github/CHANGELOG.md +560 -533
- package/templates/.github/adapters/SETUP.md +314 -314
- package/templates/.github/adapters/confluence.md +295 -295
- package/templates/.github/adapters/errors.md +234 -234
- package/templates/.github/adapters/filesystem.md +353 -353
- package/templates/.github/adapters/interface.md +301 -301
- package/templates/.github/adapters/naming.md +241 -241
- package/templates/.github/adapters/registry.md +244 -244
- package/templates/.github/agents/backend-coder.md +215 -215
- package/templates/.github/agents/db-coder.md +165 -165
- package/templates/.github/agents/doc-writer.md +66 -66
- package/templates/.github/agents/frontend-coder.md +222 -222
- package/templates/.github/agents/infra-coder.md +341 -341
- package/templates/.github/agents/reviewer.md +99 -99
- package/templates/.github/agents/security-reviewer.md +153 -153
- package/templates/.github/copilot-instructions.md +272 -272
- package/templates/.github/instructions/docs.instructions.md +147 -145
- package/templates/.github/instructions/sensitive-files.instructions.md +32 -32
- package/templates/.github/rules.md +229 -229
- package/templates/.github/scripts/bootstrap-confluence.js +289 -289
- package/templates/.github/skills/sf-design/SKILL.md +161 -161
- package/templates/.github/skills/sf-dev/SKILL.md +204 -204
- package/templates/.github/skills/sf-discovery/SKILL.md +415 -415
- package/templates/.github/skills/sf-extract/SKILL.md +225 -225
- package/templates/.github/skills/sf-load/SKILL.md +296 -296
- package/templates/.github/skills/sf-mcp/SKILL.md +386 -386
- package/templates/.github/skills/sf-merge-docs/SKILL.md +152 -152
- package/templates/.github/skills/sf-plan/SKILL.md +152 -152
- package/templates/.github/skills/sf-publish/SKILL.md +144 -144
- package/templates/.github/skills/sf-session-finish/SKILL.md +93 -93
- package/templates/.github/skills/sf-start/SKILL.md +192 -192
- package/templates/.github/templates/estrutura/apiContracts.template.md +160 -159
- package/templates/.github/templates/estrutura/architecture.template.md +169 -168
- package/templates/.github/templates/estrutura/conventions.template.md +214 -212
- package/templates/.github/templates/estrutura/decisions.template.md +107 -107
- package/templates/.github/templates/estrutura/domain.template.md +161 -160
- package/templates/.github/templates/feature/PRD.template.md +279 -279
- package/templates/.github/templates/feature/Progresso.template.md +141 -141
- package/templates/.github/templates/feature/TRD.template.md +358 -358
- package/templates/.github/templates/feature/context.template.md +89 -89
- package/templates/.github/templates/feature/extract-log.template.md +49 -49
- package/templates/.github/templates/feature/projetos.template.yaml +79 -79
- package/templates/.github/templates/global/progresso_global.template.md +59 -57
- package/templates/.github/templates/specs/brief.template.md +66 -66
- package/templates/.github/templates/specs/contracts.template.md +147 -147
- package/templates/.github/templates/specs/scenarios.template.md +125 -125
- package/templates/.github/templates/specs/tasks.template.md +65 -65
- package/templates/_gitignore +35 -35
- package/templates/sfw.config.yml.example +147 -147
|
@@ -1,353 +1,353 @@
|
|
|
1
|
-
# FilesystemAdapter — Runbook
|
|
2
|
-
|
|
3
|
-
> Implementação do `SourceAdapter` para diretórios em disco.
|
|
4
|
-
>
|
|
5
|
-
> Serve **dois propósitos**:
|
|
6
|
-
>
|
|
7
|
-
> 1. **Backend real** — times offline, diretório compartilhado em NAS,
|
|
8
|
-
> workflow 100% local sem Confluence/Notion.
|
|
9
|
-
> 2. **Mock natural pra testes E2E** — configurar `sfw.config.yml` apontando
|
|
10
|
-
> pra pasta de fixtures e o pipeline rodar inteiro sem mocks manuais.
|
|
11
|
-
> Ganho colateral citado em §9.9 (napkin): "Zero lib de mock, zero stub".
|
|
12
|
-
>
|
|
13
|
-
> Contrato conceitual: `.github/adapters/interface.md`.
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## Meta
|
|
18
|
-
|
|
19
|
-
| Campo | Valor |
|
|
20
|
-
|-------|-------|
|
|
21
|
-
| `name` | `filesystem` |
|
|
22
|
-
| Transporte | Node.js `fs` (ou equivalente na runtime) |
|
|
23
|
-
| Credenciais | Nenhuma — permissões do processo |
|
|
24
|
-
| Conflict detection | Hash-based (sha256 do conteúdo) |
|
|
25
|
-
| Suporta attachments? | ✅ via subpasta `attachments/` por convenção |
|
|
26
|
-
| Suporta busca? | Fora da interface |
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## Config
|
|
31
|
-
|
|
32
|
-
Passada via `sfw.config.yml > input.config` ou `output.targets[].config`.
|
|
33
|
-
|
|
34
|
-
| Campo | Obrig. | Tipo | Descrição |
|
|
35
|
-
|-------|:---:|------|-----------|
|
|
36
|
-
| `root_path` | ✅ | string | Path absoluto ou relativo à raiz do projeto. Ex: `"./mirror"`, `"/srv/shared/sfw"`. |
|
|
37
|
-
| `glob` | — | string | Pattern de arquivos que contam como "content". Default `"**/*.md"`. |
|
|
38
|
-
| `create_if_missing` | — | boolean | Default `true`. Se `false` e `root_path` não existe → `ValidationError`. |
|
|
39
|
-
| `follow_symlinks` | — | boolean | Default `false`. Segurança: evita escape da root via symlinks. |
|
|
40
|
-
|
|
41
|
-
### `validateConfig`
|
|
42
|
-
|
|
43
|
-
Lança `ValidationError` se:
|
|
44
|
-
- `root_path` ausente, vazio ou não-string
|
|
45
|
-
- `root_path` não existe e `create_if_missing === false`
|
|
46
|
-
- `glob` presente mas não-string
|
|
47
|
-
- `follow_symlinks` presente mas não-boolean
|
|
48
|
-
|
|
49
|
-
Resolve `root_path` pra absoluto e armazena (evita ambiguidade entre
|
|
50
|
-
operações subsequentes).
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
## Conceitos
|
|
55
|
-
|
|
56
|
-
### IDs
|
|
57
|
-
|
|
58
|
-
`id` de qualquer item é o **path relativo a `root_path`**, sempre com
|
|
59
|
-
separador `/` normalizado. Ex:
|
|
60
|
-
- `root_path = "./mirror"`
|
|
61
|
-
- Arquivo em `./mirror/workspace/Input/app_barbearia/brief.md`
|
|
62
|
-
- `id = "workspace/Input/app_barbearia/brief.md"`
|
|
63
|
-
|
|
64
|
-
**Por que relativo**: logs ficam portáveis entre máquinas. Dev em Mac e
|
|
65
|
-
CI em Linux podem compartilhar `.ai/publish-log.md` sem precisar reescrever.
|
|
66
|
-
|
|
67
|
-
### Version = sha256
|
|
68
|
-
|
|
69
|
-
`getVersion` retorna `sha256(content)` hexadecimal, sem prefixo. Sempre
|
|
70
|
-
calculado sob demanda — nunca cacheado.
|
|
71
|
-
|
|
72
|
-
**Por que sha256**: detecta qualquer mudança de byte, inclusive alterações
|
|
73
|
-
de whitespace. É o equivalente honesto do `version.number` do Confluence
|
|
74
|
-
quando não há um contador monotônico disponível.
|
|
75
|
-
|
|
76
|
-
**Trade-off aceito**: mesmo conteúdo em dois arquivos tem a mesma version —
|
|
77
|
-
isso é OK porque `id` (path) é que identifica o item, não version.
|
|
78
|
-
|
|
79
|
-
### Container vs item
|
|
80
|
-
|
|
81
|
-
| Estrutura filesystem | `type` do Item |
|
|
82
|
-
|---|---|
|
|
83
|
-
| Diretório qualquer | `folder` |
|
|
84
|
-
| Arquivo que bate com `glob` | `file` |
|
|
85
|
-
| Arquivo que NÃO bate com `glob` | ignorado (não aparece em `listChildren`) |
|
|
86
|
-
| Symlink (se `follow_symlinks: false`) | ignorado |
|
|
87
|
-
|
|
88
|
-
Não há `"page"` no filesystem — só `folder` e `file`.
|
|
89
|
-
|
|
90
|
-
### Attachments por convenção
|
|
91
|
-
|
|
92
|
-
`fetchAttachments(itemId)` procura uma subpasta `attachments/` **irmã** do
|
|
93
|
-
item. Exemplo:
|
|
94
|
-
|
|
95
|
-
```
|
|
96
|
-
workspace/Input/app_barbearia/
|
|
97
|
-
├── brief.md ← item
|
|
98
|
-
└── attachments/ ← lidos como attachments de brief.md
|
|
99
|
-
├── diagrama.png
|
|
100
|
-
└── modelagem.sql
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
**Regra**: attachments são compartilhados por todos os itens do mesmo
|
|
104
|
-
diretório. Se dois arquivos estão na mesma pasta, ambos "enxergam" os mesmos
|
|
105
|
-
attachments. Simplifica o mental model ("tudo junto na pasta").
|
|
106
|
-
|
|
107
|
-
---
|
|
108
|
-
|
|
109
|
-
## Mapeamento da interface → filesystem
|
|
110
|
-
|
|
111
|
-
### `listChildren(containerId) → Item[]`
|
|
112
|
-
|
|
113
|
-
**Passos**:
|
|
114
|
-
1. Resolver path: `fullPath = path.join(root_path, containerId)`
|
|
115
|
-
2. Validar que `fullPath` está dentro de `root_path` (prevenção de traversal):
|
|
116
|
-
- Se `path.relative(root_path, fullPath)` começa com `..` → `ValidationError`
|
|
117
|
-
3. Chamar `fs.readdir(fullPath, { withFileTypes: true })`
|
|
118
|
-
4. Para cada entry:
|
|
119
|
-
- Ignorar nomes iniciando com `.` (`.git`, `.DS_Store`, etc.)
|
|
120
|
-
- Ignorar `attachments/` (reservado)
|
|
121
|
-
- Ignorar symlinks se `follow_symlinks === false`
|
|
122
|
-
- Ignorar arquivos que não batem com `glob` E que não são diretórios
|
|
123
|
-
5. Montar `Item` pra cada entry válida:
|
|
124
|
-
- `id` = path relativo normalizado (`/` como separador)
|
|
125
|
-
- `title` = `entry.name`
|
|
126
|
-
- `version` = sha256 do conteúdo (arquivo) ou `"dir"` (diretório)
|
|
127
|
-
- `type` = `"folder"` se dir, `"file"` se arquivo
|
|
128
|
-
- `hasChildren` = `true` se dir, `false` se file
|
|
129
|
-
6. Retornar array ordenado alfabeticamente por `title`
|
|
130
|
-
|
|
131
|
-
**Erros**:
|
|
132
|
-
- Path fora da root → `ValidationError`
|
|
133
|
-
- Diretório não existe → `NotFoundError { kind: "container", itemId: containerId }`
|
|
134
|
-
- Sem permissão de leitura → `AuthError { hint: "sem permissão de leitura em {path}" }`
|
|
135
|
-
- EIO/ENOSPC/etc → `TransportError { retryable: false }`
|
|
136
|
-
|
|
137
|
-
---
|
|
138
|
-
|
|
139
|
-
### `fetchContent(itemId) → { content, metadata }`
|
|
140
|
-
|
|
141
|
-
**Passos**:
|
|
142
|
-
1. Resolver path (mesma validação anti-traversal)
|
|
143
|
-
2. `stat` no path:
|
|
144
|
-
- Se diretório → `ValidationError` (`fetchContent` só funciona em file)
|
|
145
|
-
- Se não existe → `NotFoundError { kind: "item" }`
|
|
146
|
-
3. `fs.readFile(fullPath, "utf-8")` → `content`
|
|
147
|
-
4. Montar `metadata`:
|
|
148
|
-
- `version` = sha256(content) hex
|
|
149
|
-
- `size` = bytes
|
|
150
|
-
- `mtime` = ISO string do `stat.mtime`
|
|
151
|
-
- `ctime` = ISO string do `stat.ctime`
|
|
152
|
-
- `absolute_path` = `fullPath` (útil pra debug)
|
|
153
|
-
5. Retornar `{ content, metadata }`
|
|
154
|
-
|
|
155
|
-
**Nota**: arquivos não-UTF-8 (binários) deveriam estar em `attachments/`,
|
|
156
|
-
não em `content`. Se o glob pegou um binário por engano, aceitar e deixar o
|
|
157
|
-
caller lidar — não é responsabilidade do adapter filtrar.
|
|
158
|
-
|
|
159
|
-
---
|
|
160
|
-
|
|
161
|
-
### `fetchAttachments(itemId) → Attachment[]`
|
|
162
|
-
|
|
163
|
-
**Passos**:
|
|
164
|
-
1. Resolver path do item
|
|
165
|
-
2. Calcular path da subpasta attachments:
|
|
166
|
-
- `attachDir = path.join(path.dirname(fullPath), "attachments")`
|
|
167
|
-
3. Se `attachDir` não existe → retornar `[]` (não é erro)
|
|
168
|
-
4. `fs.readdir(attachDir, { withFileTypes: true })`
|
|
169
|
-
5. Pra cada arquivo (ignorar subdirs e hidden):
|
|
170
|
-
- `stat` pra pegar `size`
|
|
171
|
-
- Detectar `mimeType` por extensão (mapa simples: `.png → image/png`,
|
|
172
|
-
`.pdf → application/pdf`, `.sql → text/x-sql`, fallback `application/octet-stream`)
|
|
173
|
-
- `download()` = closure que chama `fs.readFile(path, null)` → `Buffer`
|
|
174
|
-
6. Retornar array
|
|
175
|
-
|
|
176
|
-
---
|
|
177
|
-
|
|
178
|
-
### `getVersion(itemId) → string`
|
|
179
|
-
|
|
180
|
-
**Passos**:
|
|
181
|
-
1. Resolver path, validar existe e é arquivo
|
|
182
|
-
2. Ler conteúdo (`fs.readFile`)
|
|
183
|
-
3. Retornar `sha256(content)` hex lowercase
|
|
184
|
-
|
|
185
|
-
**Trade-off**: lê o arquivo inteiro pra calcular hash. Pra MVP é aceitável —
|
|
186
|
-
arquivos de spec/doc são pequenos (KB). Se precisar otimizar, substituir por
|
|
187
|
-
`mtime` em nanosegundos (menos honesto mas mais barato).
|
|
188
|
-
|
|
189
|
-
---
|
|
190
|
-
|
|
191
|
-
### `create(parentId, title, content) → { itemId, version }`
|
|
192
|
-
|
|
193
|
-
**Passos**:
|
|
194
|
-
1. Resolver `parentDir = path.join(root_path, parentId)`, validar anti-traversal
|
|
195
|
-
2. Garantir que `parentDir` existe — se não e `create_if_missing`, criar via `mkdirp`
|
|
196
|
-
3. `fullPath = path.join(parentDir, title)`
|
|
197
|
-
4. Se arquivo já existe → `ConflictError { kind: "duplicate_title", itemId: <novo id relativo> }`
|
|
198
|
-
5. `fs.writeFile(fullPath, content, "utf-8")`
|
|
199
|
-
6. Calcular `version = sha256(content)`
|
|
200
|
-
7. Retornar `{ itemId: <relative path>, version }`
|
|
201
|
-
|
|
202
|
-
**Convenção sobre `title`**: o caller já aplica `naming.output_artifact`
|
|
203
|
-
(ex: `"app_barbearia - PRD"`). O adapter **adiciona extensão `.md`** se o
|
|
204
|
-
title não já tiver uma — filesystem exige extensão, Confluence não. Esta é a
|
|
205
|
-
assimetria principal entre os 2 adapters.
|
|
206
|
-
|
|
207
|
-
Exemplo: `title = "app_barbearia - PRD"` → arquivo `app_barbearia - PRD.md`.
|
|
208
|
-
|
|
209
|
-
---
|
|
210
|
-
|
|
211
|
-
### `update(itemId, content, expectedVersion) → { version, ok, conflict? }`
|
|
212
|
-
|
|
213
|
-
**Passos**:
|
|
214
|
-
1. Resolver path, validar existe
|
|
215
|
-
2. Chamar `getVersion(itemId)` → `currentVersion` (sha256 do conteúdo atual)
|
|
216
|
-
3. Se `currentVersion !== expectedVersion`:
|
|
217
|
-
- Retornar `{ version: currentVersion, ok: false, conflict: true }`
|
|
218
|
-
4. `fs.writeFile(fullPath, content, "utf-8")`
|
|
219
|
-
5. `newVersion = sha256(content)`
|
|
220
|
-
6. Retornar `{ version: newVersion, ok: true }`
|
|
221
|
-
|
|
222
|
-
**Race window**: igual ao Confluence — pequena janela entre `getVersion` e
|
|
223
|
-
`writeFile`. MVP aceita. Se virar dor, usar `proper-lockfile` ou flock.
|
|
224
|
-
|
|
225
|
-
---
|
|
226
|
-
|
|
227
|
-
### `attachFile(itemId, filename, content, mimeType)`
|
|
228
|
-
|
|
229
|
-
**Passos**:
|
|
230
|
-
1. Resolver path do item, achar diretório pai
|
|
231
|
-
2. `attachDir = path.join(dirname, "attachments")`
|
|
232
|
-
3. `mkdirp(attachDir)` se não existe
|
|
233
|
-
4. `fs.writeFile(path.join(attachDir, filename), content)` (bytes)
|
|
234
|
-
5. `mimeType` é ignorado (filesystem deriva da extensão; metadata não persiste)
|
|
235
|
-
|
|
236
|
-
**Implementação opcional no MVP** — FilesystemAdapter pode deixar como
|
|
237
|
-
`NotImplementedError` se o caller nunca chamar. Facilita ter-a versão
|
|
238
|
-
inicial.
|
|
239
|
-
|
|
240
|
-
---
|
|
241
|
-
|
|
242
|
-
## Casos de uso
|
|
243
|
-
|
|
244
|
-
### 1. Backend real — time offline
|
|
245
|
-
|
|
246
|
-
```yaml
|
|
247
|
-
input:
|
|
248
|
-
adapter: filesystem
|
|
249
|
-
config:
|
|
250
|
-
root_path: "./workspace/Input"
|
|
251
|
-
cache:
|
|
252
|
-
local_dir: "./workspace/Input" # input já está local — cache é no-op
|
|
253
|
-
log: ".ai/load-log.md"
|
|
254
|
-
incremental: true
|
|
255
|
-
|
|
256
|
-
output:
|
|
257
|
-
targets:
|
|
258
|
-
- name: local
|
|
259
|
-
adapter: filesystem
|
|
260
|
-
config:
|
|
261
|
-
root_path: "./workspace/Output"
|
|
262
|
-
publishes: [PRD, TRD, Progresso]
|
|
263
|
-
mode: auto
|
|
264
|
-
conflict_detection: hash
|
|
265
|
-
approval_mechanism: none
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
PM edita arquivos direto em `workspace/Input/`. `/sf-load` vira basicamente
|
|
269
|
-
no-op (ou apenas copia pra pasta canônica se `root_path` for diferente).
|
|
270
|
-
`/sf-publish` escreve em `workspace/Output/`. Aprovação é off-band (Git PR,
|
|
271
|
-
review manual no arquivo, etc.).
|
|
272
|
-
|
|
273
|
-
### 2. Mock natural pra testes E2E
|
|
274
|
-
|
|
275
|
-
```yaml
|
|
276
|
-
input:
|
|
277
|
-
adapter: filesystem
|
|
278
|
-
config:
|
|
279
|
-
root_path: "./tests/fixtures/barbearia/Input"
|
|
280
|
-
|
|
281
|
-
output:
|
|
282
|
-
targets:
|
|
283
|
-
- name: assert
|
|
284
|
-
adapter: filesystem
|
|
285
|
-
config:
|
|
286
|
-
root_path: "./tests/fixtures/barbearia/actual_output"
|
|
287
|
-
publishes: [PRD, TRD, Progresso]
|
|
288
|
-
mode: auto
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
Teste:
|
|
292
|
-
1. Copia `fixtures/expected_output` pra local
|
|
293
|
-
2. Roda pipeline com este `sfw.config.yml`
|
|
294
|
-
3. `diff fixtures/expected_output fixtures/actual_output` — qualquer
|
|
295
|
-
divergência = teste falhou
|
|
296
|
-
4. Nenhuma lib de mock envolvida
|
|
297
|
-
|
|
298
|
-
---
|
|
299
|
-
|
|
300
|
-
## Segurança — path traversal
|
|
301
|
-
|
|
302
|
-
**Toda** operação que recebe `id` ou `parentId` do caller DEVE validar:
|
|
303
|
-
|
|
304
|
-
```typescript
|
|
305
|
-
const resolved = path.resolve(root_path, untrustedId);
|
|
306
|
-
const relative = path.relative(root_path, resolved);
|
|
307
|
-
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
308
|
-
throw new ValidationError({
|
|
309
|
-
field: "itemId",
|
|
310
|
-
message: `path escapa root_path: ${untrustedId}`,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
```
|
|
314
|
-
|
|
315
|
-
Sem isso, um adapter malicioso ou config errada pode ler/escrever em
|
|
316
|
-
`/etc/passwd`, `~/.ssh/`, etc.
|
|
317
|
-
|
|
318
|
-
---
|
|
319
|
-
|
|
320
|
-
## Matriz de erros fs → tipos do SFW
|
|
321
|
-
|
|
322
|
-
| errno Node.js | Tipo lançado | Detalhes |
|
|
323
|
-
|---|---|---|
|
|
324
|
-
| `ENOENT` | `NotFoundError` | `kind: "item"` ou `"container"` conforme contexto |
|
|
325
|
-
| `EACCES` / `EPERM` | `AuthError` | `hint`: path sem permissão |
|
|
326
|
-
| `EEXIST` (em `create`) | `ConflictError` | `kind: "duplicate_title"` |
|
|
327
|
-
| `ENOTDIR` (esperava dir, achou arquivo) | `ValidationError` | |
|
|
328
|
-
| `EISDIR` (esperava arquivo, achou dir) | `ValidationError` | |
|
|
329
|
-
| `EIO`, `ENOSPC` | `TransportError` | `retryable: false` |
|
|
330
|
-
| `EMFILE` (too many open files) | `TransportError` | `retryable: true` |
|
|
331
|
-
| Path fora de `root_path` | `ValidationError` | ver seção de segurança acima |
|
|
332
|
-
|
|
333
|
-
---
|
|
334
|
-
|
|
335
|
-
## O que NÃO está implementado no MVP
|
|
336
|
-
|
|
337
|
-
- **Watch mode** (`fs.watch` + eventos) — P2. Caller re-roda `/sf-load` manual.
|
|
338
|
-
- **Locking** (`proper-lockfile`, flock) — P2. Race window aceita.
|
|
339
|
-
- **Compression** — nada. Arquivos crus.
|
|
340
|
-
- **Git integration** — não lê `git status` / `git log`. Se quiser
|
|
341
|
-
versionamento Git, é outro adapter (`GitAdapter`, P2+).
|
|
342
|
-
- **Binary content em `fetchContent`** — caller deveria usar attachments.
|
|
343
|
-
Adapter aceita binário mas quebra `sha256` de forma desagradável.
|
|
344
|
-
|
|
345
|
-
---
|
|
346
|
-
|
|
347
|
-
## Referências
|
|
348
|
-
|
|
349
|
-
- Interface: `.github/adapters/interface.md`
|
|
350
|
-
- Erros: `.github/adapters/errors.md`
|
|
351
|
-
- Naming: `.github/adapters/naming.md`
|
|
352
|
-
- Registry: `.github/adapters/registry.md`
|
|
353
|
-
- ConfluenceAdapter (comparação): `.github/adapters/confluence.md`
|
|
1
|
+
# FilesystemAdapter — Runbook
|
|
2
|
+
|
|
3
|
+
> Implementação do `SourceAdapter` para diretórios em disco.
|
|
4
|
+
>
|
|
5
|
+
> Serve **dois propósitos**:
|
|
6
|
+
>
|
|
7
|
+
> 1. **Backend real** — times offline, diretório compartilhado em NAS,
|
|
8
|
+
> workflow 100% local sem Confluence/Notion.
|
|
9
|
+
> 2. **Mock natural pra testes E2E** — configurar `sfw.config.yml` apontando
|
|
10
|
+
> pra pasta de fixtures e o pipeline rodar inteiro sem mocks manuais.
|
|
11
|
+
> Ganho colateral citado em §9.9 (napkin): "Zero lib de mock, zero stub".
|
|
12
|
+
>
|
|
13
|
+
> Contrato conceitual: `.github/adapters/interface.md`.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Meta
|
|
18
|
+
|
|
19
|
+
| Campo | Valor |
|
|
20
|
+
|-------|-------|
|
|
21
|
+
| `name` | `filesystem` |
|
|
22
|
+
| Transporte | Node.js `fs` (ou equivalente na runtime) |
|
|
23
|
+
| Credenciais | Nenhuma — permissões do processo |
|
|
24
|
+
| Conflict detection | Hash-based (sha256 do conteúdo) |
|
|
25
|
+
| Suporta attachments? | ✅ via subpasta `attachments/` por convenção |
|
|
26
|
+
| Suporta busca? | Fora da interface |
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Config
|
|
31
|
+
|
|
32
|
+
Passada via `sfw.config.yml > input.config` ou `output.targets[].config`.
|
|
33
|
+
|
|
34
|
+
| Campo | Obrig. | Tipo | Descrição |
|
|
35
|
+
|-------|:---:|------|-----------|
|
|
36
|
+
| `root_path` | ✅ | string | Path absoluto ou relativo à raiz do projeto. Ex: `"./mirror"`, `"/srv/shared/sfw"`. |
|
|
37
|
+
| `glob` | — | string | Pattern de arquivos que contam como "content". Default `"**/*.md"`. |
|
|
38
|
+
| `create_if_missing` | — | boolean | Default `true`. Se `false` e `root_path` não existe → `ValidationError`. |
|
|
39
|
+
| `follow_symlinks` | — | boolean | Default `false`. Segurança: evita escape da root via symlinks. |
|
|
40
|
+
|
|
41
|
+
### `validateConfig`
|
|
42
|
+
|
|
43
|
+
Lança `ValidationError` se:
|
|
44
|
+
- `root_path` ausente, vazio ou não-string
|
|
45
|
+
- `root_path` não existe e `create_if_missing === false`
|
|
46
|
+
- `glob` presente mas não-string
|
|
47
|
+
- `follow_symlinks` presente mas não-boolean
|
|
48
|
+
|
|
49
|
+
Resolve `root_path` pra absoluto e armazena (evita ambiguidade entre
|
|
50
|
+
operações subsequentes).
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Conceitos
|
|
55
|
+
|
|
56
|
+
### IDs
|
|
57
|
+
|
|
58
|
+
`id` de qualquer item é o **path relativo a `root_path`**, sempre com
|
|
59
|
+
separador `/` normalizado. Ex:
|
|
60
|
+
- `root_path = "./mirror"`
|
|
61
|
+
- Arquivo em `./mirror/workspace/Input/app_barbearia/brief.md`
|
|
62
|
+
- `id = "workspace/Input/app_barbearia/brief.md"`
|
|
63
|
+
|
|
64
|
+
**Por que relativo**: logs ficam portáveis entre máquinas. Dev em Mac e
|
|
65
|
+
CI em Linux podem compartilhar `.ai/publish-log.md` sem precisar reescrever.
|
|
66
|
+
|
|
67
|
+
### Version = sha256
|
|
68
|
+
|
|
69
|
+
`getVersion` retorna `sha256(content)` hexadecimal, sem prefixo. Sempre
|
|
70
|
+
calculado sob demanda — nunca cacheado.
|
|
71
|
+
|
|
72
|
+
**Por que sha256**: detecta qualquer mudança de byte, inclusive alterações
|
|
73
|
+
de whitespace. É o equivalente honesto do `version.number` do Confluence
|
|
74
|
+
quando não há um contador monotônico disponível.
|
|
75
|
+
|
|
76
|
+
**Trade-off aceito**: mesmo conteúdo em dois arquivos tem a mesma version —
|
|
77
|
+
isso é OK porque `id` (path) é que identifica o item, não version.
|
|
78
|
+
|
|
79
|
+
### Container vs item
|
|
80
|
+
|
|
81
|
+
| Estrutura filesystem | `type` do Item |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Diretório qualquer | `folder` |
|
|
84
|
+
| Arquivo que bate com `glob` | `file` |
|
|
85
|
+
| Arquivo que NÃO bate com `glob` | ignorado (não aparece em `listChildren`) |
|
|
86
|
+
| Symlink (se `follow_symlinks: false`) | ignorado |
|
|
87
|
+
|
|
88
|
+
Não há `"page"` no filesystem — só `folder` e `file`.
|
|
89
|
+
|
|
90
|
+
### Attachments por convenção
|
|
91
|
+
|
|
92
|
+
`fetchAttachments(itemId)` procura uma subpasta `attachments/` **irmã** do
|
|
93
|
+
item. Exemplo:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
workspace/Input/app_barbearia/
|
|
97
|
+
├── brief.md ← item
|
|
98
|
+
└── attachments/ ← lidos como attachments de brief.md
|
|
99
|
+
├── diagrama.png
|
|
100
|
+
└── modelagem.sql
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Regra**: attachments são compartilhados por todos os itens do mesmo
|
|
104
|
+
diretório. Se dois arquivos estão na mesma pasta, ambos "enxergam" os mesmos
|
|
105
|
+
attachments. Simplifica o mental model ("tudo junto na pasta").
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Mapeamento da interface → filesystem
|
|
110
|
+
|
|
111
|
+
### `listChildren(containerId) → Item[]`
|
|
112
|
+
|
|
113
|
+
**Passos**:
|
|
114
|
+
1. Resolver path: `fullPath = path.join(root_path, containerId)`
|
|
115
|
+
2. Validar que `fullPath` está dentro de `root_path` (prevenção de traversal):
|
|
116
|
+
- Se `path.relative(root_path, fullPath)` começa com `..` → `ValidationError`
|
|
117
|
+
3. Chamar `fs.readdir(fullPath, { withFileTypes: true })`
|
|
118
|
+
4. Para cada entry:
|
|
119
|
+
- Ignorar nomes iniciando com `.` (`.git`, `.DS_Store`, etc.)
|
|
120
|
+
- Ignorar `attachments/` (reservado)
|
|
121
|
+
- Ignorar symlinks se `follow_symlinks === false`
|
|
122
|
+
- Ignorar arquivos que não batem com `glob` E que não são diretórios
|
|
123
|
+
5. Montar `Item` pra cada entry válida:
|
|
124
|
+
- `id` = path relativo normalizado (`/` como separador)
|
|
125
|
+
- `title` = `entry.name`
|
|
126
|
+
- `version` = sha256 do conteúdo (arquivo) ou `"dir"` (diretório)
|
|
127
|
+
- `type` = `"folder"` se dir, `"file"` se arquivo
|
|
128
|
+
- `hasChildren` = `true` se dir, `false` se file
|
|
129
|
+
6. Retornar array ordenado alfabeticamente por `title`
|
|
130
|
+
|
|
131
|
+
**Erros**:
|
|
132
|
+
- Path fora da root → `ValidationError`
|
|
133
|
+
- Diretório não existe → `NotFoundError { kind: "container", itemId: containerId }`
|
|
134
|
+
- Sem permissão de leitura → `AuthError { hint: "sem permissão de leitura em {path}" }`
|
|
135
|
+
- EIO/ENOSPC/etc → `TransportError { retryable: false }`
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### `fetchContent(itemId) → { content, metadata }`
|
|
140
|
+
|
|
141
|
+
**Passos**:
|
|
142
|
+
1. Resolver path (mesma validação anti-traversal)
|
|
143
|
+
2. `stat` no path:
|
|
144
|
+
- Se diretório → `ValidationError` (`fetchContent` só funciona em file)
|
|
145
|
+
- Se não existe → `NotFoundError { kind: "item" }`
|
|
146
|
+
3. `fs.readFile(fullPath, "utf-8")` → `content`
|
|
147
|
+
4. Montar `metadata`:
|
|
148
|
+
- `version` = sha256(content) hex
|
|
149
|
+
- `size` = bytes
|
|
150
|
+
- `mtime` = ISO string do `stat.mtime`
|
|
151
|
+
- `ctime` = ISO string do `stat.ctime`
|
|
152
|
+
- `absolute_path` = `fullPath` (útil pra debug)
|
|
153
|
+
5. Retornar `{ content, metadata }`
|
|
154
|
+
|
|
155
|
+
**Nota**: arquivos não-UTF-8 (binários) deveriam estar em `attachments/`,
|
|
156
|
+
não em `content`. Se o glob pegou um binário por engano, aceitar e deixar o
|
|
157
|
+
caller lidar — não é responsabilidade do adapter filtrar.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### `fetchAttachments(itemId) → Attachment[]`
|
|
162
|
+
|
|
163
|
+
**Passos**:
|
|
164
|
+
1. Resolver path do item
|
|
165
|
+
2. Calcular path da subpasta attachments:
|
|
166
|
+
- `attachDir = path.join(path.dirname(fullPath), "attachments")`
|
|
167
|
+
3. Se `attachDir` não existe → retornar `[]` (não é erro)
|
|
168
|
+
4. `fs.readdir(attachDir, { withFileTypes: true })`
|
|
169
|
+
5. Pra cada arquivo (ignorar subdirs e hidden):
|
|
170
|
+
- `stat` pra pegar `size`
|
|
171
|
+
- Detectar `mimeType` por extensão (mapa simples: `.png → image/png`,
|
|
172
|
+
`.pdf → application/pdf`, `.sql → text/x-sql`, fallback `application/octet-stream`)
|
|
173
|
+
- `download()` = closure que chama `fs.readFile(path, null)` → `Buffer`
|
|
174
|
+
6. Retornar array
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### `getVersion(itemId) → string`
|
|
179
|
+
|
|
180
|
+
**Passos**:
|
|
181
|
+
1. Resolver path, validar existe e é arquivo
|
|
182
|
+
2. Ler conteúdo (`fs.readFile`)
|
|
183
|
+
3. Retornar `sha256(content)` hex lowercase
|
|
184
|
+
|
|
185
|
+
**Trade-off**: lê o arquivo inteiro pra calcular hash. Pra MVP é aceitável —
|
|
186
|
+
arquivos de spec/doc são pequenos (KB). Se precisar otimizar, substituir por
|
|
187
|
+
`mtime` em nanosegundos (menos honesto mas mais barato).
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### `create(parentId, title, content) → { itemId, version }`
|
|
192
|
+
|
|
193
|
+
**Passos**:
|
|
194
|
+
1. Resolver `parentDir = path.join(root_path, parentId)`, validar anti-traversal
|
|
195
|
+
2. Garantir que `parentDir` existe — se não e `create_if_missing`, criar via `mkdirp`
|
|
196
|
+
3. `fullPath = path.join(parentDir, title)`
|
|
197
|
+
4. Se arquivo já existe → `ConflictError { kind: "duplicate_title", itemId: <novo id relativo> }`
|
|
198
|
+
5. `fs.writeFile(fullPath, content, "utf-8")`
|
|
199
|
+
6. Calcular `version = sha256(content)`
|
|
200
|
+
7. Retornar `{ itemId: <relative path>, version }`
|
|
201
|
+
|
|
202
|
+
**Convenção sobre `title`**: o caller já aplica `naming.output_artifact`
|
|
203
|
+
(ex: `"app_barbearia - PRD"`). O adapter **adiciona extensão `.md`** se o
|
|
204
|
+
title não já tiver uma — filesystem exige extensão, Confluence não. Esta é a
|
|
205
|
+
assimetria principal entre os 2 adapters.
|
|
206
|
+
|
|
207
|
+
Exemplo: `title = "app_barbearia - PRD"` → arquivo `app_barbearia - PRD.md`.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
### `update(itemId, content, expectedVersion) → { version, ok, conflict? }`
|
|
212
|
+
|
|
213
|
+
**Passos**:
|
|
214
|
+
1. Resolver path, validar existe
|
|
215
|
+
2. Chamar `getVersion(itemId)` → `currentVersion` (sha256 do conteúdo atual)
|
|
216
|
+
3. Se `currentVersion !== expectedVersion`:
|
|
217
|
+
- Retornar `{ version: currentVersion, ok: false, conflict: true }`
|
|
218
|
+
4. `fs.writeFile(fullPath, content, "utf-8")`
|
|
219
|
+
5. `newVersion = sha256(content)`
|
|
220
|
+
6. Retornar `{ version: newVersion, ok: true }`
|
|
221
|
+
|
|
222
|
+
**Race window**: igual ao Confluence — pequena janela entre `getVersion` e
|
|
223
|
+
`writeFile`. MVP aceita. Se virar dor, usar `proper-lockfile` ou flock.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
### `attachFile(itemId, filename, content, mimeType)`
|
|
228
|
+
|
|
229
|
+
**Passos**:
|
|
230
|
+
1. Resolver path do item, achar diretório pai
|
|
231
|
+
2. `attachDir = path.join(dirname, "attachments")`
|
|
232
|
+
3. `mkdirp(attachDir)` se não existe
|
|
233
|
+
4. `fs.writeFile(path.join(attachDir, filename), content)` (bytes)
|
|
234
|
+
5. `mimeType` é ignorado (filesystem deriva da extensão; metadata não persiste)
|
|
235
|
+
|
|
236
|
+
**Implementação opcional no MVP** — FilesystemAdapter pode deixar como
|
|
237
|
+
`NotImplementedError` se o caller nunca chamar. Facilita ter-a versão
|
|
238
|
+
inicial.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Casos de uso
|
|
243
|
+
|
|
244
|
+
### 1. Backend real — time offline
|
|
245
|
+
|
|
246
|
+
```yaml
|
|
247
|
+
input:
|
|
248
|
+
adapter: filesystem
|
|
249
|
+
config:
|
|
250
|
+
root_path: "./workspace/Input"
|
|
251
|
+
cache:
|
|
252
|
+
local_dir: "./workspace/Input" # input já está local — cache é no-op
|
|
253
|
+
log: ".ai/load-log.md"
|
|
254
|
+
incremental: true
|
|
255
|
+
|
|
256
|
+
output:
|
|
257
|
+
targets:
|
|
258
|
+
- name: local
|
|
259
|
+
adapter: filesystem
|
|
260
|
+
config:
|
|
261
|
+
root_path: "./workspace/Output"
|
|
262
|
+
publishes: [PRD, TRD, Progresso]
|
|
263
|
+
mode: auto
|
|
264
|
+
conflict_detection: hash
|
|
265
|
+
approval_mechanism: none
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
PM edita arquivos direto em `workspace/Input/`. `/sf-load` vira basicamente
|
|
269
|
+
no-op (ou apenas copia pra pasta canônica se `root_path` for diferente).
|
|
270
|
+
`/sf-publish` escreve em `workspace/Output/`. Aprovação é off-band (Git PR,
|
|
271
|
+
review manual no arquivo, etc.).
|
|
272
|
+
|
|
273
|
+
### 2. Mock natural pra testes E2E
|
|
274
|
+
|
|
275
|
+
```yaml
|
|
276
|
+
input:
|
|
277
|
+
adapter: filesystem
|
|
278
|
+
config:
|
|
279
|
+
root_path: "./tests/fixtures/barbearia/Input"
|
|
280
|
+
|
|
281
|
+
output:
|
|
282
|
+
targets:
|
|
283
|
+
- name: assert
|
|
284
|
+
adapter: filesystem
|
|
285
|
+
config:
|
|
286
|
+
root_path: "./tests/fixtures/barbearia/actual_output"
|
|
287
|
+
publishes: [PRD, TRD, Progresso]
|
|
288
|
+
mode: auto
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Teste:
|
|
292
|
+
1. Copia `fixtures/expected_output` pra local
|
|
293
|
+
2. Roda pipeline com este `sfw.config.yml`
|
|
294
|
+
3. `diff fixtures/expected_output fixtures/actual_output` — qualquer
|
|
295
|
+
divergência = teste falhou
|
|
296
|
+
4. Nenhuma lib de mock envolvida
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Segurança — path traversal
|
|
301
|
+
|
|
302
|
+
**Toda** operação que recebe `id` ou `parentId` do caller DEVE validar:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
const resolved = path.resolve(root_path, untrustedId);
|
|
306
|
+
const relative = path.relative(root_path, resolved);
|
|
307
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
308
|
+
throw new ValidationError({
|
|
309
|
+
field: "itemId",
|
|
310
|
+
message: `path escapa root_path: ${untrustedId}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Sem isso, um adapter malicioso ou config errada pode ler/escrever em
|
|
316
|
+
`/etc/passwd`, `~/.ssh/`, etc.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Matriz de erros fs → tipos do SFW
|
|
321
|
+
|
|
322
|
+
| errno Node.js | Tipo lançado | Detalhes |
|
|
323
|
+
|---|---|---|
|
|
324
|
+
| `ENOENT` | `NotFoundError` | `kind: "item"` ou `"container"` conforme contexto |
|
|
325
|
+
| `EACCES` / `EPERM` | `AuthError` | `hint`: path sem permissão |
|
|
326
|
+
| `EEXIST` (em `create`) | `ConflictError` | `kind: "duplicate_title"` |
|
|
327
|
+
| `ENOTDIR` (esperava dir, achou arquivo) | `ValidationError` | |
|
|
328
|
+
| `EISDIR` (esperava arquivo, achou dir) | `ValidationError` | |
|
|
329
|
+
| `EIO`, `ENOSPC` | `TransportError` | `retryable: false` |
|
|
330
|
+
| `EMFILE` (too many open files) | `TransportError` | `retryable: true` |
|
|
331
|
+
| Path fora de `root_path` | `ValidationError` | ver seção de segurança acima |
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## O que NÃO está implementado no MVP
|
|
336
|
+
|
|
337
|
+
- **Watch mode** (`fs.watch` + eventos) — P2. Caller re-roda `/sf-load` manual.
|
|
338
|
+
- **Locking** (`proper-lockfile`, flock) — P2. Race window aceita.
|
|
339
|
+
- **Compression** — nada. Arquivos crus.
|
|
340
|
+
- **Git integration** — não lê `git status` / `git log`. Se quiser
|
|
341
|
+
versionamento Git, é outro adapter (`GitAdapter`, P2+).
|
|
342
|
+
- **Binary content em `fetchContent`** — caller deveria usar attachments.
|
|
343
|
+
Adapter aceita binário mas quebra `sha256` de forma desagradável.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Referências
|
|
348
|
+
|
|
349
|
+
- Interface: `.github/adapters/interface.md`
|
|
350
|
+
- Erros: `.github/adapters/errors.md`
|
|
351
|
+
- Naming: `.github/adapters/naming.md`
|
|
352
|
+
- Registry: `.github/adapters/registry.md`
|
|
353
|
+
- ConfluenceAdapter (comparação): `.github/adapters/confluence.md`
|