@ingeniomaps/cauce 0.68.0 → 0.70.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/CHANGELOG.md +159 -0
- package/README.md +13 -6
- package/automatization/AGENTS.md +1 -1
- package/automatization/runners/antigravity/rules/cauce.md +1 -1
- package/automatization/runners/claude/CLAUDE.md +1 -1
- package/automatization/runners/codex/AGENTS.md +1 -1
- package/automatization/runners/gemini/GEMINI.md +1 -1
- package/automatization/shared/skills/autobuild/SKILL.md +1 -1
- package/automatization/workflows/autobuild.js +44 -8
- package/engine/cli/archive.js +87 -0
- package/engine/cli/args.js +7 -2
- package/engine/cli/catalog.js +9 -1
- package/engine/cli/claims.js +125 -0
- package/engine/cli/ops.js +17 -4
- package/engine/cli/planning.js +158 -102
- package/engine/cli/worktree.js +89 -0
- package/engine/core/ownership.js +16 -5
- package/engine/core/repos.js +67 -0
- package/engine/hooks/files.js +3 -2
- package/engine/planning/adoption.js +1 -1
- package/engine/planning/claims.js +153 -0
- package/engine/planning/contracts.js +43 -202
- package/engine/planning/parser.js +89 -33
- package/engine/planning/recurring.js +148 -0
- package/engine/planning/state.js +39 -6
- package/engine/planning/structure.js +220 -0
- package/package.json +1 -1
- package/template/.gitattributes +19 -0
- package/template/AGENTS.md +54 -5
- package/template/Makefile +4 -1
- package/template/automatization/AGENTS.md +1 -1
- package/template/gitignore +9 -0
- package/template/planning/BACKLOG.md +5 -0
- package/template/planning/FLOW.md +3 -1
- package/template/planning/PROTOCOL.md +25 -8
- package/template/planning/README.md +4 -3
- package/template/planning/RECURRING.md +77 -0
- package/template/planning/adr/system/OPS-001-planificacion-como-fuente-de-verdad.md +3 -2
- package/template/planning/business-rules/system/BR-OPS-001-una-sola-tarea-activa.md +7 -4
- package/template/planning/business-rules/system/BR-OPS-005-una-tarea-un-runner.md +44 -0
- package/template/planning/claims/README.md +70 -0
- package/template/planning/delivery/README.md +1 -0
- package/template/planning/delivery/multi-repo.md +11 -0
- package/template/planning/delivery/teamwork.md +162 -0
- package/template/planning/done/README.md +41 -0
- package/template/planning/wip/README.md +49 -0
- package/template/planning/DONE.md +0 -13
- package/template/planning/WIP.md +0 -22
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// En qué repositorio vive un servicio, y cuándo se movió por última vez una rama. Vive acá porque lo
|
|
4
|
+
// preguntan dos cosas que no se conocen entre sí —preparar un árbol de trabajo y juzgar si un reclamo
|
|
5
|
+
// sigue vivo— y la resolución tiene que ser la misma en las dos: escrita dos veces, una copia envejece
|
|
6
|
+
// y las dos respuestas dejan de coincidir sin que nada falle.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs')
|
|
9
|
+
const path = require('node:path')
|
|
10
|
+
const { spawnSync } = require('node:child_process')
|
|
11
|
+
|
|
12
|
+
const git = (cwd, ...args) => spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
13
|
+
|
|
14
|
+
// Los repositorios cuyo árbol contiene el servicio, resuelto como lo resuelve `check` para juzgar si
|
|
15
|
+
// existe. Devuelve la raíz git de cada uno, que no siempre es la raíz declarada: `workspaceRoots` puede
|
|
16
|
+
// apuntar a un subdirectorio.
|
|
17
|
+
//
|
|
18
|
+
// Devuelve una lista y no el primero porque con varias raíces la respuesta puede ser ambigua: un
|
|
19
|
+
// `service: .` existe en todas, y un `src` puede existir en dos. Elegir el primero da una respuesta
|
|
20
|
+
// plausible y equivocada —un árbol de trabajo en el repositorio que no era— sin que nada lo diga.
|
|
21
|
+
function reposFor(opsRoot, service) {
|
|
22
|
+
let config = {}
|
|
23
|
+
try {
|
|
24
|
+
config = JSON.parse(fs.readFileSync(path.join(opsRoot, 'ops.config.json'), 'utf8'))
|
|
25
|
+
} catch { return [] }
|
|
26
|
+
return (Array.isArray(config.workspaceRoots) ? config.workspaceRoots : [])
|
|
27
|
+
.filter((one) => one && one.path)
|
|
28
|
+
.map((one) => path.resolve(opsRoot, one.path))
|
|
29
|
+
.filter((root) => fs.existsSync(path.join(root, service || '.')))
|
|
30
|
+
.map((root) => {
|
|
31
|
+
const top = git(root, 'rev-parse', '--show-toplevel')
|
|
32
|
+
return top.status === 0 ? top.stdout.trim() : ''
|
|
33
|
+
})
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
// Dos raíces del mismo repositorio son un solo repositorio: lo ambiguo es a cuál pertenece el
|
|
36
|
+
// servicio, no cuántas rutas lo contienen.
|
|
37
|
+
.filter((repo, index, todos) => todos.indexOf(repo) === index)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// El repositorio del servicio cuando no hay duda. Sin ninguno o con varios devuelve vacío, y quien
|
|
41
|
+
// pregunta decide qué decir: para `check` es la degradación ya declarada —mirar sólo la fecha—, y para
|
|
42
|
+
// `worktree` es un error que tiene que nombrar los candidatos.
|
|
43
|
+
function repoOf(opsRoot, service) {
|
|
44
|
+
const repos = reposFor(opsRoot, service)
|
|
45
|
+
return repos.length === 1 ? repos[0] : ''
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// La fecha del último commit **propio** de una rama, en AAAA-MM-DD, o vacío si no tiene ninguno. Vacío no
|
|
49
|
+
// es un error: una tarea recién tomada todavía no tiene rama, la rama recién creada no tiene commits, y
|
|
50
|
+
// un proyecto puede nombrar sus ramas de otra forma. Quien pregunta decide qué hacer con la ausencia.
|
|
51
|
+
//
|
|
52
|
+
// Lo que hay que excluir es el tronco. Una rama nueva hereda su historia entera, así que preguntar por su
|
|
53
|
+
// último commit a secas devuelve el del tronco y **toda rama parece haber avanzado el día que se creó** —
|
|
54
|
+
// que es justo lo contrario de lo que esta función existe para medir. El tronco es la rama en la que está
|
|
55
|
+
// el árbol principal: los worktrees se crean desde ahí y ahí se queda.
|
|
56
|
+
function lastCommit(repo, branch) {
|
|
57
|
+
if (!repo) return ''
|
|
58
|
+
const actual = git(repo, 'rev-parse', '--abbrev-ref', 'HEAD')
|
|
59
|
+
const tronco = actual.status === 0 ? actual.stdout.trim() : ''
|
|
60
|
+
const args = tronco && tronco !== branch
|
|
61
|
+
? ['log', '-1', '--format=%cs', branch, '--not', tronco, '--']
|
|
62
|
+
: ['log', '-1', '--format=%cs', branch, '--']
|
|
63
|
+
const shown = git(repo, ...args)
|
|
64
|
+
return shown.status === 0 ? shown.stdout.trim() : ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { reposFor, repoOf, lastCommit }
|
package/engine/hooks/files.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
} = require('./input')
|
|
13
13
|
const AP = require('./approval')
|
|
14
14
|
const { readWip } = require('../planning/parser')
|
|
15
|
+
const { runner } = require('../planning/claims')
|
|
15
16
|
const { hasTasks } = require('../planning/state')
|
|
16
17
|
const { TEMPLATE_PREFIXES } = require('../core/ownership')
|
|
17
18
|
|
|
@@ -138,7 +139,7 @@ function planFirst(input) {
|
|
|
138
139
|
const root = opsRoot(input)
|
|
139
140
|
if (!root) return
|
|
140
141
|
const planning = path.join(root, 'planning')
|
|
141
|
-
const wip = readWip(planning)
|
|
142
|
+
const wip = readWip(planning, runner())
|
|
142
143
|
if (wip && wip.complete + wip.pending > 0) return
|
|
143
144
|
// Una instancia recién creada no tiene de dónde sacar una tarea: `onboard` deja el roadmap vacío y
|
|
144
145
|
// dice que alguien lo llene. Exigir el plan ahí es un candado delante de la puerta, y la salida que
|
|
@@ -149,7 +150,7 @@ function planFirst(input) {
|
|
|
149
150
|
if (!hasTasks(planning)) return
|
|
150
151
|
const estado = wip ? `WIP tiene la tarea ${wip.task} y ningún paso` : 'WIP está en IDLE'
|
|
151
152
|
const why = `${estado}, así que el plan todavía no está escrito.\n`
|
|
152
|
-
+ 'Escribí en planning/
|
|
153
|
+
+ 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
|
|
153
154
|
+ 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
|
|
154
155
|
+ AP.HOW('OPS_PLAN_FIRST_OVERRIDE')
|
|
155
156
|
for (const raw of filesOf(input)) {
|
|
@@ -96,7 +96,7 @@ function report({ done, epics = [], adopted = [] }) {
|
|
|
96
96
|
for (const slug of slugs) {
|
|
97
97
|
const entry = done.entries.find((candidate) => candidate.slug === slug)
|
|
98
98
|
if (!entry) {
|
|
99
|
-
warnings.push(`${BASELINE}: ${slug} no está en
|
|
99
|
+
warnings.push(`${BASELINE}: ${slug} no está en done/; sacalo de la lista`)
|
|
100
100
|
} else if (!PC.doneEntryErrors(entry, epics).length) {
|
|
101
101
|
warnings.push(`${BASELINE}: ${slug} ya cumple el contrato; retiralo poniéndole \`#~\` delante`)
|
|
102
102
|
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Quién tomó qué. Un archivo por tarea reclamada: crearlo es reclamar, borrarlo es soltar.
|
|
4
|
+
//
|
|
5
|
+
// El nombre del archivo es el slug de la tarea, y de ahí sale la única propiedad que importa: dos
|
|
6
|
+
// personas en tareas distintas no tocan el mismo archivo nunca, y dos que reclaman la misma chocan en
|
|
7
|
+
// git — que es exactamente donde el choque significa algo y donde conviene verlo.
|
|
8
|
+
//
|
|
9
|
+
// El plan de la ejecución no vive acá. Eso es `wip/<runner>.md`, que es local y cambia en cada paso; acá va lo
|
|
10
|
+
// poco que el resto del equipo necesita saber, que cambia dos veces por tarea. Son dos responsabilidades
|
|
11
|
+
// distintas y por eso son dos archivos: el reclamo evita que dos runners tomen la misma tarea, y el WIP
|
|
12
|
+
// evita que un runner lleve dos.
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs')
|
|
15
|
+
const { spawnSync } = require('node:child_process')
|
|
16
|
+
const path = require('node:path')
|
|
17
|
+
const P = require('./parser')
|
|
18
|
+
|
|
19
|
+
const DIR = 'claims'
|
|
20
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
21
|
+
|
|
22
|
+
// Tres días **sin ninguna señal de avance**, que no es lo mismo que tres días desde que se tomó. El
|
|
23
|
+
// tiempo transcurrido solo no distingue una tarea larga de una abandonada, y equivocarse en esa
|
|
24
|
+
// distinción es caro en los dos sentidos: apurar a alguien que está trabajando, o dejar bloqueada para
|
|
25
|
+
// siempre la tarea de quien se fue.
|
|
26
|
+
//
|
|
27
|
+
// Lo que sí distingue es si la rama de la tarea se movió. Con esa señal, tres días sin un solo commit no
|
|
28
|
+
// es una tarea larga: es una que se detuvo, y el aviso manda a mirar y no a soltar.
|
|
29
|
+
const STALE_DAYS = 3
|
|
30
|
+
|
|
31
|
+
// Quién soy. Sale de la identidad de git porque ya está configurada, es por máquina y es la que va a
|
|
32
|
+
// terminar en el commit igual: pedir una segunda identidad sólo para esto sería un dato más que puede
|
|
33
|
+
// quedar desincronizado. `CAUCE_OWNER` la pisa donde no la haya —un contenedor de CI, por ejemplo—.
|
|
34
|
+
//
|
|
35
|
+
// Sin identidad no se puede reclamar, y eso es correcto: un reclamo anónimo no le dice a nadie a quién
|
|
36
|
+
// preguntarle.
|
|
37
|
+
function owner(root) {
|
|
38
|
+
if (process.env.CAUCE_OWNER) return process.env.CAUCE_OWNER.trim()
|
|
39
|
+
const result = spawnSync('git', ['config', 'user.email'], { cwd: root, encoding: 'utf8' })
|
|
40
|
+
return result.status === 0 ? (result.stdout || '').trim() : ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Qué runner soy, que no es lo mismo que quién soy. En una máquina con varios agentes la identidad de
|
|
44
|
+
// git es la misma para todos —`git config user.email` no distingue una sesión de otra—, así que si lo
|
|
45
|
+
// que decide «esto es mío» fuera el owner, el segundo agente tomaría por propia la tarea del primero y
|
|
46
|
+
// los dos construirían lo mismo. La unidad es el árbol de trabajo: uno por agente.
|
|
47
|
+
//
|
|
48
|
+
// `CAUCE_RUNNER` es la vía explícita y la que deja `ops worktree`. Sin ella se deduce del árbol donde
|
|
49
|
+
// corre el proceso, que acierta cuando el agente invoca desde el suyo y falla —devolviendo el mismo id
|
|
50
|
+
// para todos— cuando invoca desde una instancia sidecar compartida. Esa falla no queda en silencio:
|
|
51
|
+
// `claim` se niega a darle una segunda tarea a un runner que ya tiene una, y ese es el mensaje que
|
|
52
|
+
// manda a poner la variable.
|
|
53
|
+
function runner() {
|
|
54
|
+
if (process.env.CAUCE_RUNNER) return process.env.CAUCE_RUNNER.trim()
|
|
55
|
+
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
|
56
|
+
return result.status === 0 ? (result.stdout || '').trim() : process.cwd()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// La rama donde vive el trabajo de una tarea. La escriben `worktree` al crearla y `check` al buscar si
|
|
60
|
+
// se movió, y son la misma o el segundo mira una rama que nadie usa.
|
|
61
|
+
const branchOf = (slug) => `task/${slug}`
|
|
62
|
+
|
|
63
|
+
function file(root, slug) {
|
|
64
|
+
return path.join(root, DIR, `${slug}.md`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function read(root) {
|
|
68
|
+
let names = []
|
|
69
|
+
try { names = fs.readdirSync(path.join(root, DIR)) } catch { return [] }
|
|
70
|
+
return names.filter((name) => name.endsWith('.md') && name !== 'README.md').sort()
|
|
71
|
+
.map((name) => {
|
|
72
|
+
const field = P.frontmatter(P.read(path.join(root, DIR, name)))
|
|
73
|
+
return {
|
|
74
|
+
slug: name.replace(/\.md$/, ''),
|
|
75
|
+
task: field('task'),
|
|
76
|
+
owner: field('owner'),
|
|
77
|
+
runner: field('runner'),
|
|
78
|
+
started: field('started'),
|
|
79
|
+
service: field('service'),
|
|
80
|
+
at: `${DIR}/${name}`,
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// El cuerpo de un reclamo. Es corto a propósito: todo lo que crezca acá vuelve a viajar por git en cada
|
|
86
|
+
// cambio, que es de lo que este archivo vino a separarse.
|
|
87
|
+
function content({ task, owner, runner: from, started, service }) {
|
|
88
|
+
return `---\ntask: ${task}\nowner: ${owner}\nrunner: ${from}\nstarted: ${started}\n`
|
|
89
|
+
+ `service: ${service || ''}\n---\n\nTomada. El plan vive en el \`wip/\` de quien la tomó.\n`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validate({ claims, milestones, done }) {
|
|
93
|
+
const errors = []
|
|
94
|
+
const queued = new Set(milestones.flatMap((milestone) => milestone.tasks).map((task) => task.slug))
|
|
95
|
+
for (const claim of claims) {
|
|
96
|
+
const at = `${claim.at}`
|
|
97
|
+
// El nombre del archivo es lo que hace única la reserva, así que un frontmatter que dice otra cosa
|
|
98
|
+
// reclama una tarea y bloquea otra. Es la única forma en que este contrato puede mentir.
|
|
99
|
+
if (claim.task !== claim.slug) {
|
|
100
|
+
errors.push(`${at}: declara task "${claim.task}" y el archivo reserva ${claim.slug}`)
|
|
101
|
+
}
|
|
102
|
+
if (!claim.owner) errors.push(`${at}: falta owner`)
|
|
103
|
+
if (!claim.runner) errors.push(`${at}: falta runner`)
|
|
104
|
+
if (!DATE.test(claim.started)) errors.push(`${at}: started debe ser AAAA-MM-DD`)
|
|
105
|
+
if (!queued.has(claim.slug) && !done.set.has(claim.slug)) {
|
|
106
|
+
errors.push(`${at}: ${claim.slug} no existe en BACKLOG ni DONE`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return errors
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const days = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`))
|
|
113
|
+
/ 86400000)
|
|
114
|
+
|
|
115
|
+
// `activity` mapea el slug de una tarea a la fecha del último commit de su rama. Llega de afuera porque
|
|
116
|
+
// resolverlo exige git y la configuración del proyecto, y este módulo se prueba sin ninguna de las dos.
|
|
117
|
+
// Vacío es un estado legítimo —una tarea recién tomada no tiene rama— y entonces la única señal que
|
|
118
|
+
// queda es cuándo se tomó.
|
|
119
|
+
function warnings({ claims, done, today, activity = new Map() }) {
|
|
120
|
+
const lines = []
|
|
121
|
+
for (const claim of claims) {
|
|
122
|
+
if (done.set.has(claim.slug)) {
|
|
123
|
+
lines.push(`${claim.at}: ${claim.slug} ya está en DONE; soltala con \`ops release\``)
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (!DATE.test(claim.started)) continue
|
|
127
|
+
const commit = activity.get(claim.slug) || ''
|
|
128
|
+
const ultima = commit && commit > claim.started ? commit : claim.started
|
|
129
|
+
const quieta = days(ultima, today)
|
|
130
|
+
if (quieta > STALE_DAYS) {
|
|
131
|
+
const senal = commit
|
|
132
|
+
? `último commit hace ${days(commit, today)} días`
|
|
133
|
+
: 'la rama de la tarea no tiene commits'
|
|
134
|
+
lines.push(`${claim.at}: ${claim.slug} sin avanzar hace ${quieta} días `
|
|
135
|
+
+ `(${claim.owner}, tomada hace ${days(claim.started, today)}; ${senal}); mirá si sigue viva`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Dos tareas del mismo servicio pueden tocar los mismos archivos, y eso no se puede saber antes de
|
|
139
|
+
// hacerlas. Lo único que se puede es decirlo a tiempo de que hablen, así que avisa y nunca frena:
|
|
140
|
+
// frenar serializaría a un equipo entero sobre un servicio, que es peor que la colisión que evita.
|
|
141
|
+
const byService = new Map()
|
|
142
|
+
for (const claim of claims.filter((one) => one.service && !done.set.has(one.slug))) {
|
|
143
|
+
byService.set(claim.service, [...(byService.get(claim.service) || []), claim])
|
|
144
|
+
}
|
|
145
|
+
for (const [service, group] of byService) {
|
|
146
|
+
if (group.length < 2) continue
|
|
147
|
+
lines.push(`${group.length} tareas tomadas sobre ${service} `
|
|
148
|
+
+ `(${group.map((one) => `${one.slug} · ${one.owner}`).join('; ')}); pueden tocar los mismos archivos`)
|
|
149
|
+
}
|
|
150
|
+
return lines
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { DIR, STALE_DAYS, owner, runner, branchOf, file, read, content, validate, warnings }
|
|
@@ -8,7 +8,6 @@ const { PLACEHOLDERS } = require('../core/onboarding')
|
|
|
8
8
|
const TEST_TRACE = /^(?:n\/a\s*[—-]\s*.+|(?:A|C\d+)\s*(?:→|->)\s*\S.+)$/i
|
|
9
9
|
const DECISION_TRACE = /\[(?:fuente|supuesto):\s*[^\]]+\]/i
|
|
10
10
|
const COMMIT_TRACE = /^(?:n\/a\s*[—-]\s*.+|[0-9a-f]{7,40}\s+\S.*)$/i
|
|
11
|
-
const EPIC_AUXILIARY_FILES = new Set(['notes.md', 'plan.md', 'research.md', 'spec.md'])
|
|
12
11
|
|
|
13
12
|
function validTestTrace(value) {
|
|
14
13
|
return String(value || '').split(/\s*;\s*/).filter(Boolean)
|
|
@@ -75,6 +74,9 @@ function doneEntryErrors(entry, epics = []) {
|
|
|
75
74
|
const at = `${entry.source} ${entry.slug}`
|
|
76
75
|
const errors = []
|
|
77
76
|
if (!entry.acceptance) errors.push(`${at}: falta acept:`)
|
|
77
|
+
// La fecha de cierre. Mientras las entradas vivían en un archivo, el orden lo daba la posición; con un
|
|
78
|
+
// archivo por tarea no hay posición, y sin fecha no hay forma de saber cuál se cerró antes.
|
|
79
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.fecha)) errors.push(`${at}: falta fecha: AAAA-MM-DD`)
|
|
78
80
|
if (!entry.done) errors.push(`${at}: falta done:`)
|
|
79
81
|
if (!entry.qa) errors.push(`${at}: falta qa:`)
|
|
80
82
|
if (!entry.commit) errors.push(`${at}: falta commit:`)
|
|
@@ -117,201 +119,41 @@ function validateEpic(epic, done = new Set()) {
|
|
|
117
119
|
return errors
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
// Las dependencias declaradas, contra lo que existe y contra sí mismas. Dos errores distintos y los dos
|
|
123
|
+
// dejan tareas que no se le ofrecen a nadie: una que depende de algo que no existe no está lista nunca, y
|
|
124
|
+
// un ciclo se traba entero. Las dos se ven como una cola que no avanza y sin causa visible.
|
|
125
|
+
function dependencyErrors(milestones, done) {
|
|
124
126
|
const errors = []
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
`
|
|
132
|
-
+ 'o un directorio epic-NNN-<slug>/ con spec.md adentro.',
|
|
133
|
-
)
|
|
134
|
-
continue
|
|
135
|
-
}
|
|
136
|
-
if (!entry.isDirectory() || !/^epic-\d{3}-/.test(entry.name)) continue
|
|
137
|
-
const epicDir = path.join(roadmap, entry.name)
|
|
138
|
-
if (!fs.existsSync(path.join(epicDir, 'spec.md'))) {
|
|
139
|
-
errors.push(`roadmap/${entry.name}: falta spec.md`)
|
|
140
|
-
}
|
|
141
|
-
for (const child of fs.readdirSync(epicDir, { withFileTypes: true })) {
|
|
142
|
-
if (!child.isFile() || !EPIC_AUXILIARY_FILES.has(child.name)) {
|
|
143
|
-
errors.push(`roadmap/${entry.name}/${child.name}: archivo auxiliar no permitido`)
|
|
127
|
+
const tasks = milestones.flatMap((milestone) => milestone.tasks)
|
|
128
|
+
const queued = new Map(tasks.map((task) => [task.slug, task.depends || []]))
|
|
129
|
+
for (const task of tasks) {
|
|
130
|
+
for (const dep of task.depends || []) {
|
|
131
|
+
if (dep === task.slug) errors.push(`BACKLOG ${task.slug}: depende de sí misma`)
|
|
132
|
+
else if (!queued.has(dep) && !done.set.has(dep)) {
|
|
133
|
+
errors.push(`BACKLOG ${task.slug}: depende de ${dep}, que no existe en BACKLOG ni DONE`)
|
|
144
134
|
}
|
|
145
135
|
}
|
|
146
136
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
for (const line of text.split('\n')) {
|
|
159
|
-
const heading = line.match(P.MILESTONE_HEADING)
|
|
160
|
-
if (heading) { milestone = heading[1]; continue }
|
|
161
|
-
if (/^##\s+Hito\b/.test(line)) {
|
|
162
|
-
errors.push(`BACKLOG "${line.trim()}": encabezado inválido; se escribe ## Hito <slug> — <Título>, `
|
|
163
|
-
+ 'y sin él las tareas que vienen abajo quedan huérfanas')
|
|
164
|
-
milestone = ''
|
|
165
|
-
continue
|
|
137
|
+
// Recorrido en profundidad con el camino a cuestas: al reencontrar un slug que sigue en el camino,
|
|
138
|
+
// ese camino **es** el ciclo, y nombrarlo entero es lo que lo hace reparable — decir sólo que hay uno
|
|
139
|
+
// deja el trabajo de encontrarlo del lado de quien lee.
|
|
140
|
+
const estado = new Map()
|
|
141
|
+
const visitar = (slug, camino) => {
|
|
142
|
+
if (estado.get(slug) === 'listo') return
|
|
143
|
+
const desde = camino.indexOf(slug)
|
|
144
|
+
if (desde >= 0) {
|
|
145
|
+
const ciclo = [...camino.slice(desde), slug]
|
|
146
|
+
errors.push(`BACKLOG: ciclo de dependencias ${ciclo.join(' → ')}`)
|
|
147
|
+
return
|
|
166
148
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (lane) {
|
|
171
|
-
errors.push(`BACKLOG ${lane[1].trim()}: lane "${lane[2]}" no existe; usá ${P.LANES.join(' | ')}, `
|
|
172
|
-
+ 'o dejá la tarea sin clasificar')
|
|
173
|
-
continue
|
|
174
|
-
}
|
|
175
|
-
const at = `BACKLOG hito ${milestone}: no la lee nadie`
|
|
176
|
-
if (/^-\s+\[[xX]\]/.test(line)) {
|
|
177
|
-
errors.push(`${at} — ${line.trim().slice(0, 60)}. Una tarea terminada se mueve a DONE.md, no se tilda acá.`)
|
|
178
|
-
continue
|
|
179
|
-
}
|
|
180
|
-
errors.push(`${at} — ${line.trim().slice(0, 60)}. Una tarea se escribe `
|
|
181
|
-
+ '`- [ ] **slug** [lane] — descripción`, con `(→ CN) (epic: NNN)` o `_Aceptación:_` después del guión.')
|
|
182
|
-
}
|
|
183
|
-
return errors
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// El número de una regla es su identificador, y lo cita todo el sistema: cargos, workflows, plantillas
|
|
187
|
-
// y entradas de DONE. El override se declara escribiendo un archivo con el mismo nombre que el del
|
|
188
|
-
// sistema —ahí redefinir sus números es el punto—; en cualquier otro archivo, reusar un `R` crea una
|
|
189
|
-
// segunda definición que nadie declaró y que ninguna herramienta veía. Las propias se numeran `P`.
|
|
190
|
-
function ruleIds(file) {
|
|
191
|
-
return [...P.read(file).matchAll(/^##\s+([A-Z]\d+)\s+[—-]/gm)].map((match) => match[1])
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Qué IDs deja de regir un override por nombre: los que definía el archivo del sistema y el propio no
|
|
195
|
-
// redefine. Reemplazar el archivo entero es la función del override y está documentada; lo que no se
|
|
196
|
-
// veía es la consecuencia, porque la advertencia nombraba el par de archivos y no la diferencia. El
|
|
197
|
-
// caso caro es una regla que el motor sigue exigiendo —R17 lo hace—: queda exigida y sin estar escrita
|
|
198
|
-
// en ningún lado, y quien la vea fallar la va a buscar en `rules/`, donde ya no está.
|
|
199
|
-
// Dos secciones de una épica que compiten por el mismo rol. El parser prefiere la exacta, así que
|
|
200
|
-
// resuelve —y en silencio: quien escribió las dos no se entera de que una se ignora entera. La
|
|
201
|
-
// promoción dejó de generarlas cuando lo importado empezó a bajar un nivel; a mano se siguen pudiendo
|
|
202
|
-
// escribir, y ahí el aviso es lo único que lo dice.
|
|
203
|
-
function competingSections(dir) {
|
|
204
|
-
const roadmap = path.join(dir, 'roadmap')
|
|
205
|
-
const avisos = []
|
|
206
|
-
let files = []
|
|
207
|
-
try { files = fs.readdirSync(roadmap).filter((file) => /^epic-\d{3}-/.test(file)) } catch { return [] }
|
|
208
|
-
for (const file of files.sort()) {
|
|
209
|
-
const text = P.read(path.join(roadmap, file))
|
|
210
|
-
const titles = [...text.matchAll(/^##\s+(.+)$/gm)].map((hit) => hit[1].trim())
|
|
211
|
-
for (const role of [/Criterios/i, /Historias/i]) {
|
|
212
|
-
const casan = titles.filter((title) => role.test(title))
|
|
213
|
-
if (casan.length < 2) continue
|
|
214
|
-
const exact = new RegExp(`^${role.source}$`, role.flags)
|
|
215
|
-
const gana = casan.find((title) => exact.test(title)) || casan[0]
|
|
216
|
-
const ignoradas = casan.filter((title) => title !== gana)
|
|
217
|
-
avisos.push(`roadmap/${file}: "## ${gana}" convive con "## ${ignoradas.join('", "## ')}"; `
|
|
218
|
-
+ 'sólo se lee la primera y el resto se ignora entero')
|
|
149
|
+
for (const dep of queued.get(slug) || []) {
|
|
150
|
+
// La que se depende a sí misma ya tiene su error, más claro que un ciclo de un solo paso.
|
|
151
|
+
if (dep !== slug && queued.has(dep)) visitar(dep, [...camino, slug])
|
|
219
152
|
}
|
|
153
|
+
estado.set(slug, 'listo')
|
|
220
154
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
function retiredByOverride(dir, name) {
|
|
225
|
-
const rules = path.join(dir, 'rules')
|
|
226
|
-
const system = path.join(rules, 'system', name)
|
|
227
|
-
if (!fs.existsSync(system)) return []
|
|
228
|
-
const redefined = ruleIds(path.join(rules, name))
|
|
229
|
-
return ruleIds(system).filter((id) => !redefined.includes(id))
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function validateRules(dir) {
|
|
233
|
-
const rules = path.join(dir, 'rules')
|
|
234
|
-
const owner = new Map()
|
|
235
|
-
const errors = []
|
|
236
|
-
const files = (sub) => {
|
|
237
|
-
try {
|
|
238
|
-
return fs.readdirSync(path.join(rules, sub), { withFileTypes: true })
|
|
239
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md')
|
|
240
|
-
.map((entry) => entry.name).sort()
|
|
241
|
-
} catch { return [] }
|
|
242
|
-
}
|
|
243
|
-
const system = new Set(files('system'))
|
|
244
|
-
for (const name of system) {
|
|
245
|
-
for (const id of ruleIds(path.join(rules, 'system', name))) {
|
|
246
|
-
if (owner.has(id)) errors.push(`rules/system/${name}: ${id} ya lo define ${owner.get(id)}`)
|
|
247
|
-
else owner.set(id, `rules/system/${name}`)
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
for (const name of files('')) {
|
|
251
|
-
// El override se declara por nombre: redefinir los números del archivo que reemplaza es su función.
|
|
252
|
-
if (system.has(name)) continue
|
|
253
|
-
for (const id of ruleIds(path.join(rules, name))) {
|
|
254
|
-
const definedBy = owner.get(id)
|
|
255
|
-
if (!definedBy) { owner.set(id, `rules/${name}`); continue }
|
|
256
|
-
errors.push(definedBy.startsWith('rules/system/')
|
|
257
|
-
? `rules/${name}: ${id} ya lo define ${definedBy}; una regla propia se numera P1..Pn, `
|
|
258
|
-
+ 'o vive en un archivo con el mismo nombre para declarar el override'
|
|
259
|
-
: `rules/${name}: ${id} ya lo define ${definedBy}`)
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
return errors
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Una decisión que no dice si rige no decide nada, y el molde traía el menú entero en la línea de estado:
|
|
266
|
-
// casi una de cada cinco decisiones escritas con este modelo se publicó con el menú intacto. Presentar
|
|
267
|
-
// las opciones no obliga a elegir; esto sí. Las secciones son las cuatro que se escriben siempre —las
|
|
268
|
-
// alternativas quedan en el molde sin exigirse, porque pedirlas rechazaría a casi todas las que existen—.
|
|
269
|
-
const ADR_STATES = ['Propuesto', 'Aceptado', 'Obsoleto']
|
|
270
|
-
const ADR_SUPERSEDED = /^Reemplazada por \[[^\]]+\]\([^)]+\)(?: \(\d{4}-\d{2}-\d{2}\))?$/
|
|
271
|
-
const ADR_SECTIONS = ['Contexto', 'Decisión', 'Consecuencias', 'Estado de implementación']
|
|
272
|
-
|
|
273
|
-
function validateAdrFile(at, text) {
|
|
274
|
-
const errors = []
|
|
275
|
-
const declared = ((text.match(/^\*\*Estado:\*\*\s*(.+?)\s*$/m) || [])[1] || '').trim()
|
|
276
|
-
if (!declared) errors.push(`${at}: falta **Estado:**`)
|
|
277
|
-
else if (declared.includes('|')) errors.push(`${at}: el estado sigue siendo el menú de la plantilla; elegí uno`)
|
|
278
|
-
else if (!ADR_STATES.includes(declared) && !ADR_SUPERSEDED.test(declared)) {
|
|
279
|
-
errors.push(`${at}: estado "${declared}" fuera de ${ADR_STATES.join(' | ')} `
|
|
280
|
-
+ '| Reemplazada por [NNN](NNN-slug.md)')
|
|
281
|
-
}
|
|
282
|
-
for (const section of ADR_SECTIONS) {
|
|
283
|
-
if (!new RegExp(`^##\\s+${section}\\s*$`, 'm').test(text)) errors.push(`${at}: falta ## ${section}`)
|
|
284
|
-
}
|
|
285
|
-
return errors
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// El nombre lleva el id porque de ahí sale la identidad con que se detecta un override, y porque una
|
|
289
|
-
// decisión se cita por número. Sin él, el archivo existe y no lo alcanza ninguna referencia.
|
|
290
|
-
function validateAdr(dir) {
|
|
291
|
-
const adr = path.join(dir, 'adr')
|
|
292
|
-
const errors = []
|
|
293
|
-
const numbers = new Map()
|
|
294
|
-
const scan = (sub, pattern) => {
|
|
295
|
-
let entries = []
|
|
296
|
-
try { entries = fs.readdirSync(path.join(adr, sub), { withFileTypes: true }) } catch { return }
|
|
297
|
-
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
298
|
-
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
299
|
-
if (entry.name === 'README.md' || entry.name === '000-template.md') continue
|
|
300
|
-
const at = `adr/${sub ? `${sub}/` : ''}${entry.name}`
|
|
301
|
-
const id = entry.name.match(pattern)
|
|
302
|
-
if (!id) {
|
|
303
|
-
errors.push(`${at}: nadie lo lee como decisión. Una ADR se nombra NNN-<slug>.md, `
|
|
304
|
-
+ 'y la del sistema <ID>-NNN-<slug>.md en system/.')
|
|
305
|
-
continue
|
|
306
|
-
}
|
|
307
|
-
if (numbers.has(id[1])) errors.push(`${at}: ${id[1]} ya lo usa ${numbers.get(id[1])}`)
|
|
308
|
-
else numbers.set(id[1], at)
|
|
309
|
-
errors.push(...validateAdrFile(at, P.read(path.join(adr, sub, entry.name))))
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
scan('', /^(\d{3})-[a-z0-9-]+\.md$/)
|
|
313
|
-
scan('system', /^([A-Z][A-Z0-9]*-\d{3})-[a-z0-9-]+\.md$/)
|
|
314
|
-
return errors
|
|
155
|
+
for (const slug of queued.keys()) visitar(slug, [])
|
|
156
|
+
return [...new Set(errors)]
|
|
315
157
|
}
|
|
316
158
|
|
|
317
159
|
// Todo lo que se juzga sobre el estado ya leído: épicas, hitos, tareas, WIP, evidencia y acciones
|
|
@@ -319,7 +161,7 @@ function validateAdr(dir) {
|
|
|
319
161
|
// `validateDoneEntry`, `validateRules`— y estaba creciendo del otro lado sólo porque ahí era más
|
|
320
162
|
// rápido escribirla. No lee nada: recibe el estado, así que se prueba sin tocar disco.
|
|
321
163
|
function validateState({
|
|
322
|
-
epics, milestones, done,
|
|
164
|
+
epics, milestones, done, wips = [], roles = new Set(), humanActions = [], adopted = new Set(),
|
|
323
165
|
}) {
|
|
324
166
|
const errors = []
|
|
325
167
|
const epicNums = new Set()
|
|
@@ -399,15 +241,20 @@ function validateState({
|
|
|
399
241
|
}
|
|
400
242
|
}
|
|
401
243
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
244
|
+
errors.push(...dependencyErrors(milestones, done))
|
|
245
|
+
|
|
246
|
+
for (const wip of wips) {
|
|
247
|
+
const at = `wip/${wip.runner}.md`
|
|
248
|
+
if (!backlogSlugs.has(wip.task) && !done.set.has(wip.task)) {
|
|
249
|
+
errors.push(`${at}: ${wip.task} no existe en BACKLOG ni DONE`)
|
|
250
|
+
}
|
|
405
251
|
// El WIP es el punto de retorno tras una interrupción, y el protocolo manda seguir desde el primer
|
|
406
252
|
// paso sin tildar. Un plan que el motor no puede contar se lee como un plan terminado, así que la
|
|
407
253
|
// recuperación se queda sin de dónde retomar justo cuando es lo único que quedó del trabajo.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
254
|
+
if (!wip.complete && !wip.pending) {
|
|
255
|
+
errors.push(`${at}: el plan de ${wip.task} no tiene pasos que el motor pueda contar; `
|
|
256
|
+
+ 'se escriben `1. [ ] paso`')
|
|
257
|
+
}
|
|
411
258
|
}
|
|
412
259
|
for (const row of humanActions) {
|
|
413
260
|
if (!row.valid) {
|
|
@@ -444,15 +291,9 @@ function validateState({
|
|
|
444
291
|
module.exports = {
|
|
445
292
|
validateState,
|
|
446
293
|
doneEntryErrors,
|
|
447
|
-
validateAdr,
|
|
448
|
-
validateRules,
|
|
449
|
-
retiredByOverride,
|
|
450
|
-
competingSections,
|
|
451
|
-
validateBacklogStructure,
|
|
452
294
|
validCommitTrace,
|
|
453
295
|
validDecisionTrace,
|
|
454
296
|
validTestTrace,
|
|
455
297
|
validateDoneEntry,
|
|
456
298
|
validateEpic,
|
|
457
|
-
validateRoadmapStructure,
|
|
458
299
|
}
|