@ingeniomaps/cauce 0.81.0 → 0.83.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 +248 -0
- package/automatization/hooks/README.md +10 -6
- package/automatization/hooks/guard-ops-config-shell.sh +3 -0
- package/automatization/hooks/guard-ops-config.sh +3 -0
- package/automatization/runners/antigravity/rules/cauce.md +7 -2
- package/automatization/runners/claude/CLAUDE.md +1 -4
- package/automatization/runners/codex/AGENTS.md +7 -3
- package/automatization/runners/gemini/GEMINI.md +1 -4
- package/automatization/shared/inbox.js +47 -0
- package/automatization/workflows/agent-eval.js +1 -1
- package/automatization/workflows/autobuild.js +56 -18
- package/automatization/workflows/flow.js +45 -8
- package/automatization/workflows/onboard.js +32 -3
- package/engine/automation/index.js +19 -4
- package/engine/automation/rules.js +122 -0
- package/engine/automation/runners.js +3 -1
- package/engine/cli/instance.js +35 -6
- package/engine/cli/planning.js +16 -2
- package/engine/config/validate.js +53 -3
- package/engine/core/onboarding.js +37 -7
- package/engine/core/ownership.js +64 -1
- package/engine/core/scan.js +23 -9
- package/engine/hooks/approval.js +50 -15
- package/engine/hooks/chat.js +122 -11
- package/engine/hooks/input.js +1 -11
- package/engine/hooks/ops-config.js +110 -0
- package/engine/hooks/push.js +194 -0
- package/engine/hooks/run.js +17 -2
- package/engine/hooks/secrets-shell.js +13 -2
- package/engine/hooks/self-approval.js +93 -13
- package/engine/hooks/shell.js +13 -11
- package/engine/integrations/registry.js +13 -1
- package/engine/planning/inbox.js +36 -0
- package/engine/planning/parser.js +22 -9
- package/engine/planning/recurring.js +13 -2
- package/engine/schemas/ops-config.schema.json +21 -0
- package/package.json +1 -1
- package/template/AGENTS.md +13 -5
- package/template/gitignore +5 -0
- package/template/planning/INBOX.md +2 -1
- package/template/planning/RECURRING.md +6 -5
- package/template/planning/delivery/teamwork.md +1 -1
- package/template/planning/rules/system/commits.md +3 -1
|
@@ -20,7 +20,7 @@ function validateOpsConfig(config) {
|
|
|
20
20
|
// `cauceVersion` la escribe el toolkit, no la persona: registra de qué versión salió la instancia.
|
|
21
21
|
const allowed = new Set([
|
|
22
22
|
'$schema', 'cauceVersion', 'project', 'mode', 'workspaceRoots', 'writableOutsideRoots', 'runner',
|
|
23
|
-
'migrations',
|
|
23
|
+
'migrations', 'inbox',
|
|
24
24
|
])
|
|
25
25
|
for (const key of Object.keys(config)) {
|
|
26
26
|
if (RETIRED[key]) errors.push(`ops.config.json: ${key} ya no se usa: ${RETIRED[key]}`)
|
|
@@ -34,9 +34,26 @@ function validateOpsConfig(config) {
|
|
|
34
34
|
validateWritable(config.writableOutsideRoots, errors)
|
|
35
35
|
validateRunner(config.runner, errors)
|
|
36
36
|
validateMigrations(config.migrations, errors)
|
|
37
|
+
validateInbox(config.inbox, errors)
|
|
37
38
|
return errors
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// El umbral del aviso de tamaño del INBOX. Sólo un entero positivo: un cero o un texto se leerían como
|
|
42
|
+
// «avisá siempre» o «nunca», y ninguno de los dos es algo que alguien escriba a propósito.
|
|
43
|
+
function validateInbox(inbox, errors) {
|
|
44
|
+
if (inbox === undefined) return
|
|
45
|
+
if (!inbox || typeof inbox !== 'object' || Array.isArray(inbox)) {
|
|
46
|
+
errors.push('ops.config.json: inbox debe ser un objeto')
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
for (const key of Object.keys(inbox)) {
|
|
50
|
+
if (key !== 'warnLines') errors.push(`ops.config.json: inbox.${key} no está permitido`)
|
|
51
|
+
}
|
|
52
|
+
if ('warnLines' in inbox && !(Number.isInteger(inbox.warnLines) && inbox.warnLines > 0)) {
|
|
53
|
+
errors.push('ops.config.json: inbox.warnLines debe ser un entero mayor que cero, o no estar')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
// Qué cuenta como migración para el guard. Sin declararlo, sólo `.sql` — y ése es el default que hace
|
|
41
58
|
// falta decir, porque un proyecto TypeORM, Prisma, Django o Rails tiene el guard cableado y en verde sin
|
|
42
59
|
// que mire una sola migración (caso 077).
|
|
@@ -68,11 +85,36 @@ function validateMigrations(migrations, errors) {
|
|
|
68
85
|
}
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
// Lo que la configuración tiene de dudoso y no de inválido. Va aparte de `validateOpsConfig` porque lo que
|
|
89
|
+
// aquélla devuelve son errores y la leen también los guards: una advertencia ahí sería un rechazo.
|
|
90
|
+
//
|
|
91
|
+
// Hoy sólo el nombre repetido: dos raíces con el mismo `name` dejan dos servicios indistinguibles en
|
|
92
|
+
// `check`, `onboard` y `scan`, y una credencial que no se puede atribuir a ninguno (caso 113). Avisa y no
|
|
93
|
+
// falla porque nadie eligió su `name` pensando que fuera único, así que rechazarlo le rompería la puerta a
|
|
94
|
+
// una instancia que hoy funciona, y lo que está en juego es un nombre ambiguo, no algo que se pierda.
|
|
95
|
+
function configWarnings(config) {
|
|
96
|
+
const roots = Array.isArray(config && config.workspaceRoots) ? config.workspaceRoots : []
|
|
97
|
+
const found = []
|
|
98
|
+
const named = new Map()
|
|
99
|
+
for (const workspace of roots) {
|
|
100
|
+
const name = workspace && typeof workspace.name === 'string' ? workspace.name.trim() : ''
|
|
101
|
+
if (!name) continue
|
|
102
|
+
if (named.has(name)) {
|
|
103
|
+
found.push(`ops.config.json: ${name} nombra dos raíces (${named.get(name)} y ${workspace.path}): `
|
|
104
|
+
+ 'sus servicios salen con el mismo nombre. Renombrá una')
|
|
105
|
+
} else named.set(name, workspace.path)
|
|
106
|
+
}
|
|
107
|
+
return found
|
|
108
|
+
}
|
|
109
|
+
|
|
71
110
|
function validateWorkspaces(workspaces, errors) {
|
|
72
111
|
if (!Array.isArray(workspaces) || !workspaces.length) {
|
|
73
112
|
errors.push('ops.config.json: workspaceRoots debe contener al menos una raíz')
|
|
74
113
|
return
|
|
75
114
|
}
|
|
115
|
+
// El nombre repetido no se rechaza acá: lo avisa `check`, que es quien puede hacerlo sin romperle la
|
|
116
|
+
// puerta a una instancia que hoy funciona (caso 113). Este validador lo leen también los guards, y lo
|
|
117
|
+
// que devuelve son errores: meterlo acá era decidir que la configuración es inválida.
|
|
76
118
|
for (const [index, workspace] of workspaces.entries()) {
|
|
77
119
|
if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) {
|
|
78
120
|
errors.push(`ops.config.json: workspaceRoots[${index}] debe ser un objeto`)
|
|
@@ -119,10 +161,18 @@ function validateRunner(runner, errors) {
|
|
|
119
161
|
return
|
|
120
162
|
}
|
|
121
163
|
const booleans = ['humanCheckpointBetweenMilestones', 'commitPerTask', 'allowPush']
|
|
122
|
-
const allowed = new Set(['maxTaskHours', ...booleans])
|
|
164
|
+
const allowed = new Set(['maxTaskHours', 'pushToLiveBranches', ...booleans])
|
|
123
165
|
for (const key of Object.keys(runner)) {
|
|
124
166
|
if (!allowed.has(key)) errors.push(`ops.config.json: runner.${key} no está permitido`)
|
|
125
167
|
}
|
|
168
|
+
// Un patrón haría de un permiso por rama un permiso por familia, que es lo que el campo vino a evitar
|
|
169
|
+
// (caso 108): el guard compara el nombre tal cual, y `release/*` no publicaría en ninguna.
|
|
170
|
+
const live = runner.pushToLiveBranches
|
|
171
|
+
if (live !== undefined && (!Array.isArray(live)
|
|
172
|
+
|| live.some((branch) => typeof branch !== 'string' || !/^[^\s*?[]+$/.test(branch)))) {
|
|
173
|
+
errors.push('ops.config.json: runner.pushToLiveBranches debe ser una lista de nombres de rama exactos, '
|
|
174
|
+
+ 'sin espacios ni patrones')
|
|
175
|
+
}
|
|
126
176
|
if (typeof runner.maxTaskHours !== 'number' || runner.maxTaskHours <= 0) {
|
|
127
177
|
errors.push('ops.config.json: runner.maxTaskHours debe ser mayor que cero')
|
|
128
178
|
}
|
|
@@ -133,4 +183,4 @@ function validateRunner(runner, errors) {
|
|
|
133
183
|
}
|
|
134
184
|
}
|
|
135
185
|
|
|
136
|
-
module.exports = { validateOpsConfig }
|
|
186
|
+
module.exports = { configWarnings, validateOpsConfig }
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const fs = require('node:fs')
|
|
10
10
|
const path = require('node:path')
|
|
11
11
|
const { inventory } = require('./scan')
|
|
12
|
+
const { sensitiveKey } = require('../integrations/registry')
|
|
12
13
|
|
|
13
14
|
// La raíz del paquete: el molde contra el que se compara lo que una instancia escribió.
|
|
14
15
|
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
|
|
@@ -91,11 +92,23 @@ function missingSections(root) {
|
|
|
91
92
|
return warnings
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// Una aparición cuenta sólo si lo que la rodea no puede ser parte de otro nombre de variable: buscada
|
|
96
|
+
// como subcadena, API_SECRET_ROTATION daba por cargada a API_SECRET (caso 111). El escaneo sólo deja
|
|
97
|
+
// pasar identificadores, así que el nombre entra a la expresión sin nada que escapar.
|
|
98
|
+
function named(text, name) {
|
|
99
|
+
return new RegExp(`(?<![A-Za-z0-9_])${name}(?![A-Za-z0-9_])`).test(text)
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
// Credenciales que el proyecto declara y que no aparecen en ningún contrato. El arranque tiene que
|
|
95
103
|
// dejar una fila por cada una —quién la carga y dónde— y en la práctica cubre las que se hablaron en la
|
|
96
104
|
// conversación: las que sólo estaban en el inventario se pierden, y con ellas el servicio externo que
|
|
97
105
|
// hay detrás. Una variable sin dueño no rompe nada hoy; rompe el día que alguien tiene que desplegar.
|
|
98
106
|
//
|
|
107
|
+
// Credencial es lo que tiene nombre de secreto según `sensitiveKey`, la misma regla con la que la
|
|
108
|
+
// declaración de secretos y la configuración de una integración rechazan un valor. Contando cualquier
|
|
109
|
+
// variable, el aviso listaba ciento ocho nombres de build y la única credencial quedaba en «y 1 más»
|
|
110
|
+
// (caso 102). El precio es el secreto con nombre de configuración, y el aviso lo dice.
|
|
111
|
+
//
|
|
99
112
|
// Sólo cuando la instancia ya tiene contexto escrito: antes del arranque no hay dónde estuvieran.
|
|
100
113
|
function orphanCredentials(root) {
|
|
101
114
|
if (guide(root).fresh) return []
|
|
@@ -108,16 +121,33 @@ function orphanCredentials(root) {
|
|
|
108
121
|
.join('\n')
|
|
109
122
|
if (!contracts) return []
|
|
110
123
|
const orphans = []
|
|
124
|
+
const cut = []
|
|
111
125
|
for (const service of inventory(root)) {
|
|
112
|
-
|
|
113
|
-
|
|
126
|
+
const env = service.env || {}
|
|
127
|
+
for (const name of env.names || []) {
|
|
128
|
+
if (sensitiveKey(name) && !named(contracts, name)) orphans.push({ name, service: service.path })
|
|
114
129
|
}
|
|
130
|
+
if (env.truncated) cut.push(`${service.path} (${env.truncated} de ${env.names.length + env.truncated})`)
|
|
115
131
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
const warnings = []
|
|
133
|
+
if (orphans.length) {
|
|
134
|
+
const listed = orphans.map((one) => `${one.name} (${one.service})`)
|
|
135
|
+
const summary = listed.length > 4
|
|
136
|
+
? `${listed.slice(0, 4).join(', ')} y ${listed.length - 4} más`
|
|
137
|
+
: listed.join(', ')
|
|
138
|
+
const services = [...new Set(orphans.map((one) => one.service))].join(', ')
|
|
139
|
+
warnings.push(`credenciales por nombre sin dueño (${orphans.length}, en ${services}): ${summary} — no `
|
|
140
|
+
+ 'aparecen en el mapa ni en HUMAN_ACTIONS: nadie las carga. El dueño se escribe en '
|
|
141
|
+
+ 'organization/workspace.md o en una fila de planning/HUMAN_ACTIONS.md; el criterio es el nombre, así '
|
|
142
|
+
+ 'que una credencial con nombre de configuración no aparece acá')
|
|
143
|
+
}
|
|
144
|
+
// El escaneo corta cada ejemplo en un tope, y lo que quedó afuera no se miró: con el filtro, puede ser
|
|
145
|
+
// justo la credencial.
|
|
146
|
+
if (cut.length) {
|
|
147
|
+
warnings.push(`sin revisar por credenciales sin dueño, pasado el tope de variables por servicio: `
|
|
148
|
+
+ `${cut.join(', ')} — lo que quedó afuera puede incluir una credencial que nadie carga`)
|
|
149
|
+
}
|
|
150
|
+
return warnings
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
module.exports = {
|
package/engine/core/ownership.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
const fs = require('node:fs')
|
|
9
9
|
const path = require('node:path')
|
|
10
10
|
|
|
11
|
+
// El paquete que corre: contra él se decide qué del runtime es una entrega de Cauce (casos 100 y 110).
|
|
12
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..')
|
|
13
|
+
|
|
11
14
|
// Archivos de los que el toolkit es único autor. Un proyecto que necesite cambiarlos no los
|
|
12
15
|
// edita: agrega una regla propia junto a las de `system/`, que sí sobrevive al upgrade.
|
|
13
16
|
const SYSTEM_FILES = [
|
|
@@ -176,6 +179,27 @@ function overrides(root) {
|
|
|
176
179
|
return found
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
// Las reglas que rigen una instancia, relativas a su raíz: cada `planning/rules/*.md` del proyecto y cada una de
|
|
183
|
+
// `system/` que el proyecto no sobrescribió. `overrides()` ya sabía cuál reemplaza a cuál y sólo servía para
|
|
184
|
+
// avisar; esto es lo que se entrega a quien trabaja (casos 099 y 105). Sólo el primer nivel, igual que `check`.
|
|
185
|
+
function effectiveRules(root) {
|
|
186
|
+
const dir = path.join(root, 'planning', 'rules')
|
|
187
|
+
const markdown = (sub) => {
|
|
188
|
+
try {
|
|
189
|
+
return fs.readdirSync(path.join(dir, sub), { withFileTypes: true })
|
|
190
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md'
|
|
191
|
+
&& !entry.name.startsWith('.'))
|
|
192
|
+
.map((entry) => entry.name).sort()
|
|
193
|
+
} catch { return [] }
|
|
194
|
+
}
|
|
195
|
+
const replaced = new Set(overrides(root).filter((one) => one.collection === 'planning/rules')
|
|
196
|
+
.map((one) => one.system))
|
|
197
|
+
return [
|
|
198
|
+
...markdown('system').filter((name) => !replaced.has(name)).map((name) => `planning/rules/system/${name}`),
|
|
199
|
+
...markdown('').map((name) => `planning/rules/${name}`),
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
|
|
179
203
|
// Rutas que el toolkit dejó de materializar. Sin esto una instancia arrastra para siempre lo que
|
|
180
204
|
// alguna versión suya copió: `upgrade` agrega y reemplaza, pero nunca quitaba nada.
|
|
181
205
|
// Cada archivo propio del molde, con cómo llega a una instancia que **ya existe**. `upgrade` sólo
|
|
@@ -302,7 +326,7 @@ function localChanges(root) {
|
|
|
302
326
|
for (const target of trackedPaths()) {
|
|
303
327
|
const dir = path.join(root, target)
|
|
304
328
|
if (!fs.existsSync(dir)) continue
|
|
305
|
-
for (const file of manifest.edited(root, target,
|
|
329
|
+
for (const file of manifest.edited(root, target, deliveredFiles(root, target))) changed.push(`${target}/${file}`)
|
|
306
330
|
}
|
|
307
331
|
// Los archivos sueltos del sistema entran por la misma puerta. Quedaban afuera, así que `upgrade`
|
|
308
332
|
// los reemplazaba en silencio: un cargo escribió el índice de ADR que el propio README le pedía
|
|
@@ -311,8 +335,47 @@ function localChanges(root) {
|
|
|
311
335
|
return changed
|
|
312
336
|
}
|
|
313
337
|
|
|
338
|
+
// Lo que el paquete que corre trae en una ruta del runtime. El resto de esa carpeta es de la empresa —un
|
|
339
|
+
// guard propio—, y no es una entrega: registrado, pasaba a «editado» en cuanto la empresa lo tocaba, y
|
|
340
|
+
// `upgrade --check` salía con 1 por algo que nunca fue de Cauce (caso 100).
|
|
341
|
+
function shippedFiles(relative) {
|
|
342
|
+
return new Set(treeFiles(path.join(PACKAGE_ROOT, sourceOf(relative))))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Lo que de una ruta rastreada cuenta como entregado: todo, salvo en el runtime, donde sólo lo que el
|
|
346
|
+
// paquete trae. Lo usan el registro de `init` y de `upgrade` y la detección de ediciones, que tienen que
|
|
347
|
+
// contar lo mismo.
|
|
348
|
+
function deliveredFiles(root, relative) {
|
|
349
|
+
const files = treeFiles(path.join(root, relative))
|
|
350
|
+
return RUNTIME_PATHS.includes(relative) ? files.filter((file) => shippedFiles(relative).has(file)) : files
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Lo que el paquete empieza a traer con un nombre que la instancia ya usaba para algo suyo: un guard propio
|
|
354
|
+
// que se llama como uno nuevo del toolkit (caso 110). Sin huella en el registro no cuenta como edición, así
|
|
355
|
+
// que copiar encima lo borraba sin decirlo. Sólo si el registro ya conoce la ruta: en una instancia
|
|
356
|
+
// anterior al registro, «sin huella» también es «lo entregó una versión vieja».
|
|
357
|
+
function collisions(root) {
|
|
358
|
+
const manifest = require('./manifest')
|
|
359
|
+
const recorded = manifest.read(root)
|
|
360
|
+
const found = []
|
|
361
|
+
for (const relative of RUNTIME_PATHS) {
|
|
362
|
+
if (!Object.keys(recorded).some((key) => key.startsWith(`${relative}/`))) continue
|
|
363
|
+
for (const file of shippedFiles(relative)) {
|
|
364
|
+
const local = path.join(root, relative, file)
|
|
365
|
+
if (recorded[`${relative}/${file}`] || !fs.existsSync(local)) continue
|
|
366
|
+
const shipped = path.join(PACKAGE_ROOT, sourceOf(relative), file)
|
|
367
|
+
if (manifest.digest(local) !== manifest.digest(shipped)) found.push(`${relative}/${file}`)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return found
|
|
371
|
+
}
|
|
372
|
+
|
|
314
373
|
module.exports = {
|
|
315
374
|
RETIRED,
|
|
375
|
+
collisions,
|
|
376
|
+
deliveredFiles,
|
|
377
|
+
effectiveRules,
|
|
378
|
+
shippedFiles,
|
|
316
379
|
RETIRED_COMPARTIDO,
|
|
317
380
|
TEMPLATE_OWN,
|
|
318
381
|
TEMPLATE_PREFIXES,
|
package/engine/core/scan.js
CHANGED
|
@@ -171,17 +171,28 @@ function scan(root, skip = '') {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// Dónde puede mirar una instancia: exactamente las raíces que declara,
|
|
174
|
+
// Dónde puede mirar una instancia: exactamente las raíces que declara, con el nombre que les puso. Sale
|
|
175
175
|
// de `ops.config.json` en vez de suponerse —el sidecar declara `..`, el embebido `.`— para que acotar las
|
|
176
176
|
// raíces acote también el escaneo, y para que nadie termine recorriendo la carpeta de al lado.
|
|
177
|
-
|
|
177
|
+
//
|
|
178
|
+
// El nombre cae a la carpeta cuando la raíz no lo declara: `check` lo exige, pero `scan` y `onboard`
|
|
179
|
+
// corren igual sobre una configuración que nunca pasó por ahí, y ahí el prefijo saldría `undefined`.
|
|
180
|
+
function declaredRoots(root) {
|
|
181
|
+
const fallback = [{ name: path.basename(root), dir: root }]
|
|
178
182
|
try {
|
|
179
183
|
const config = JSON.parse(fs.readFileSync(path.join(root, 'ops.config.json'), 'utf8'))
|
|
180
|
-
const declared = (config.workspaceRoots || []).map((entry) =>
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
const declared = (config.workspaceRoots || []).map((entry) => {
|
|
185
|
+
const dir = path.resolve(root, entry.path || '.')
|
|
186
|
+
return { name: String(entry.name || '').trim() || path.basename(dir), dir }
|
|
187
|
+
})
|
|
188
|
+
return declared.length ? declared : fallback
|
|
189
|
+
} catch { return fallback }
|
|
183
190
|
}
|
|
184
191
|
|
|
192
|
+
// Sólo las rutas. Es lo que mira quien acota una escritura, y `onboard --json` las emite tal cual en
|
|
193
|
+
// `roots`: darles forma de objeto habría cambiado ese contrato para quien no necesita el nombre.
|
|
194
|
+
const workspaceRoots = (root) => declaredRoots(root).map((one) => one.dir)
|
|
195
|
+
|
|
185
196
|
// Qué hay en las raíces declaradas, antes de que nadie razone sobre ello. La raíz ops se saltea: no es
|
|
186
197
|
// un servicio del proyecto, y su `package.json` sólo declara el motor.
|
|
187
198
|
// Los candidatos de una raíz, con el proyecto que vive en ella misma primero: un monolito declara sus
|
|
@@ -197,12 +208,15 @@ function candidates(workspace, skip = '') {
|
|
|
197
208
|
// Con varias raíces, cada repositorio es la raíz de su propio escaneo y su candidato principal se llama
|
|
198
209
|
// `.`: tres servicios con el mismo nombre y nada que los distinga. El prefijo los vuelve nombrables, que
|
|
199
210
|
// es la única forma de que una credencial pueda atribuirse a un servicio en vez de quedar suelta.
|
|
211
|
+
//
|
|
212
|
+
// El prefijo es el `name` declarado y no la carpeta, que es lo que dejaba a `gouduet/keycloak` y
|
|
213
|
+
// `hypixo/keycloak` llamándose las dos `keycloak` (caso 113). Que no se repita lo exige el validador.
|
|
200
214
|
function inventory(root) {
|
|
201
|
-
const roots =
|
|
202
|
-
if (roots.length === 1) return candidates(roots[0], root)
|
|
203
|
-
return roots.flatMap((
|
|
215
|
+
const roots = declaredRoots(root)
|
|
216
|
+
if (roots.length === 1) return candidates(roots[0].dir, root)
|
|
217
|
+
return roots.flatMap(({ name, dir }) => candidates(dir, root).map((service) => ({
|
|
204
218
|
...service,
|
|
205
|
-
path: service.path === '.' ?
|
|
219
|
+
path: service.path === '.' ? name : `${name}/${service.path}`,
|
|
206
220
|
})))
|
|
207
221
|
}
|
|
208
222
|
|
package/engine/hooks/approval.js
CHANGED
|
@@ -32,19 +32,39 @@ const CHAT = require('./chat')
|
|
|
32
32
|
const APPROVAL = '.ops-approval'
|
|
33
33
|
|
|
34
34
|
// Una ruta por línea, `#` para lo demás. El archivo ausente y el vacío son lo mismo: no hay nada
|
|
35
|
-
// aprobado, que es el estado normal.
|
|
35
|
+
// aprobado, que es el estado normal. Un push se aprueba igual, con la línea `push <remoto> <rama>`
|
|
36
|
+
// tal cual y sin patrones: `feat/*` convertiría una aprobación puntual en un permiso (caso 103).
|
|
37
|
+
//
|
|
38
|
+
// El texto se lee aparte del archivo porque `self-approval` compara lo que se está por escribir contra lo
|
|
39
|
+
// que ya está en disco: con dos parsers, una línea podría contar de un lado y no del otro (caso 119).
|
|
40
|
+
const lines = (text) => text.split('\n').map((line) => line.replace(/#.*$/, '').trim()).filter(Boolean)
|
|
41
|
+
|
|
36
42
|
function read(root) {
|
|
37
|
-
|
|
38
|
-
try { text = fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8') } catch { return [] }
|
|
39
|
-
return text.split('\n').map((line) => line.replace(/#.*$/, '').trim()).filter(Boolean)
|
|
43
|
+
try { return lines(fs.readFileSync(path.join(root, 'planning', APPROVAL), 'utf8')) } catch { return [] }
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
// Qué queda sin aprobar de lo que un guard está por bloquear. Se reporta sólo eso: mandar a revisar lo
|
|
43
47
|
// que ya se aprobó es lo que hace que la próxima vez nadie lea el mensaje. Cuenta también lo que la
|
|
44
48
|
// persona pidió en el chat.
|
|
45
|
-
|
|
49
|
+
const left = (root, files) => {
|
|
46
50
|
const approved = new Set(root ? read(root) : [])
|
|
47
|
-
return
|
|
51
|
+
return files.filter((file) => !approved.has(file))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pending(root, files, input) {
|
|
55
|
+
return CHAT.unauthorized(input, left(root, files))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Lo mismo para los gates de commit —`governance`, `verify` y `dependencies`—, que preguntan cada vez: no
|
|
59
|
+
// conceden nada y no heredan lo que la sesión venía concediendo.
|
|
60
|
+
//
|
|
61
|
+
// Commitear está del lado de publicar y no del de leer, y por qué esa diferencia decide quién hereda está
|
|
62
|
+
// en `push.js`. Lo propio de un commit es que el objeto del permiso se mueve solo: lo que autoriza uno no
|
|
63
|
+
// dice nada del siguiente, porque el índice ya es otro. Nadie lo había decidido para los gates —heredaban
|
|
64
|
+
// por venir todos de `pending`—, y se midió: con otro mensaje en curso, un commit de gobernanza pasaba
|
|
65
|
+
// (caso 119).
|
|
66
|
+
function pendingNow(root, files, input) {
|
|
67
|
+
return CHAT.unauthorizedNow(input, left(root, files))
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
// El archivo que el guard va a leer, nombrado desde la carpeta en la que está la sesión. En sidecar la
|
|
@@ -69,16 +89,31 @@ function where(input) {
|
|
|
69
89
|
// primero: contestar es más corto que editar un archivo, y es lo que la persona ya está haciendo. El
|
|
70
90
|
// archivo queda como cosa de ella: dicho en imperativo, el agente leía «aprobalo» como una orden para él
|
|
71
91
|
// e intentaba escribírselo en vez de reintentar, medido en una sesión real de Claude Code.
|
|
72
|
-
|
|
92
|
+
//
|
|
93
|
+
// `pasteable` es lo que se puede aprobar por archivo, y por defecto es todo: un guard lo angosta cuando lo
|
|
94
|
+
// que tiene a mano no sirve para pegar —por qué, en `secrets-shell.js` (caso 118)—. Lo frenado se anota
|
|
95
|
+
// igual, así que el «dale» sigue cubriendo todo.
|
|
96
|
+
function HOW(variable, lines, input, pasteable = lines) {
|
|
73
97
|
const chat = CHAT.hold(input, lines)
|
|
74
|
-
|
|
98
|
+
const stuck = lines.filter((one) => !pasteable.includes(one))
|
|
99
|
+
const ask = chat
|
|
75
100
|
? 'Decile a la persona qué se frenó y por qué, y esperá: si contesta «dale», reintentá el mismo cambio y '
|
|
76
|
-
+ 'pasa.
|
|
77
|
-
: '
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
101
|
+
+ 'pasa. '
|
|
102
|
+
: ''
|
|
103
|
+
const paste = pasteable.length
|
|
104
|
+
? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
|
|
105
|
+
+ ` ${where(input)} estas líneas:\n`
|
|
106
|
+
+ pasteable.map((line) => ` ${line}\n`).join('')
|
|
107
|
+
+ 'Valen para ese conjunto y dejan de valer en cuanto cambie. '
|
|
108
|
+
: ''
|
|
109
|
+
const unresolved = stuck.length
|
|
110
|
+
? `Por archivo no hay línea que pegar para ${stuck.join(', ')}: la ruta llegó con una expansión del shell `
|
|
111
|
+
+ 'sin resolver, y la aprobación compara texto, así que esa línea sólo valdría para un comando escrito '
|
|
112
|
+
+ 'igual. Volvé a correrlo con la ruta escrita y el bloqueo va a decir qué pegar. '
|
|
113
|
+
: ''
|
|
114
|
+
return ask + paste + unresolved
|
|
115
|
+
+ `La variable ${variable}=1 sigue existiendo y apaga el guard para toda la sesión, que es por lo que no `
|
|
116
|
+
+ 'es la vía recomendada.'
|
|
82
117
|
}
|
|
83
118
|
|
|
84
|
-
module.exports = { APPROVAL, read, pending, HOW }
|
|
119
|
+
module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW }
|
package/engine/hooks/chat.js
CHANGED
|
@@ -45,10 +45,41 @@ function load(session) {
|
|
|
45
45
|
// Nombrar no es pedir: «no toques el .env» nombra el .env. Cuenta la negación que está en la misma frase y
|
|
46
46
|
// antes del nombre; la coma corta, porque «leé el config, no el .env» son dos pedidos.
|
|
47
47
|
const NEGATION = /(?:^|[^\p{L}])(?:no|nunca|jam[aá]s|ni|sin|not|never|don'?t)(?![\p{L}])/iu
|
|
48
|
-
|
|
48
|
+
// Un punto corta sólo si cierra la oración: el de `x.js` es parte del nombre, y cortar ahí dejaba el verbo de
|
|
49
|
+
// «agregá src/x.js a .ops-approval» en otra frase que la del archivo.
|
|
50
|
+
const CLAUSE = /[,;:!?\n]|\.(?=\s|$)/
|
|
49
51
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
+
// Y no negarlo tampoco alcanza: «¿para qué sirven las credentials?» o «el .env tiene algo raro» nombran el
|
|
53
|
+
// archivo sin pedir nada (caso 109). La frase del nombre tiene que traer un verbo que pida una acción, en
|
|
54
|
+
// español o en inglés; se compara sin tildes y sin el pronombre pegado —«leelo», «abrime»—. La lista va a
|
|
55
|
+
// quedar corta, y lo que no reconoce no se pierde: se frena, y un «dale» lo aprueba.
|
|
56
|
+
//
|
|
57
|
+
// Autorizar también es pedir, y era lo que faltaba: «autorizo la lectura del .env» es la forma más
|
|
58
|
+
// explícita de decir que sí y se frenaba igual, porque ninguna de sus palabras estaba (caso 118). Entran
|
|
59
|
+
// las formas con que una persona **concede** —primera persona e imperativo— y no los sustantivos que
|
|
60
|
+
// nombran el acto: `lectura`, `permiso` y `autorizacion` aparecen igual en una pregunta que no autoriza
|
|
61
|
+
// nada, y dejarlos afuera es lo que el 109 decidió cuando dijo que nombrar no alcanza.
|
|
62
|
+
const ASKS = new Set(('lee leer abri abre abrir mostra muestra mostrar ensena edita editar cambia cambiar borra '
|
|
63
|
+
+ 'borrar elimina eliminar escribi escribe escribir corre correr ejecuta ejecutar desactiva desactivar apaga '
|
|
64
|
+
+ 'apagar reescribi reescribe reescribir agrega agregar anadi anade anadir saca sacar quita quitar actualiza '
|
|
65
|
+
+ 'actualizar modifica modificar revisa revisar mira mirar fijate chequea verifica verificar instala instalar '
|
|
66
|
+
+ 'subi sube subir pushea pushear commitea commitear usa usar crea crear arregla arreglar carga cargar copia '
|
|
67
|
+
+ 'copiar toca tocar aproba aprueba aprobar habilita habilitar reemplaza reemplazar renombra renombrar mueve '
|
|
68
|
+
+ 'mover restaura restaurar imprimi imprime imprimir deci dime proba probar '
|
|
69
|
+
+ 'autorizo autoriza autorizar autorizado permito permiti permite permitir apruebo habilito '
|
|
70
|
+
+ 'read open show print display edit change delete remove write run execute disable rewrite add update modify '
|
|
71
|
+
+ 'check review inspect look cat commit push install use create fix load copy touch approve enable replace '
|
|
72
|
+
+ 'rename move restore skip '
|
|
73
|
+
+ 'authorize authorized allow allowed permit permitted grant granted approved').split(' '))
|
|
74
|
+
const ENCLITIC = /(?:selo|sela|melo|mela|telo|tela|los|las|lo|la|le|me)$/
|
|
75
|
+
|
|
76
|
+
function asks(clause) {
|
|
77
|
+
const words = clause.normalize('NFD').replace(/[̀-ͯ]/g, '').match(/[a-z]+/g) || []
|
|
78
|
+
return words.some((word) => ASKS.has(word) || ASKS.has(word.replace(ENCLITIC, '')))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Cada aparición del nombre en el texto: si va negada y si su frase pide algo. Un nombre tiene que estar
|
|
82
|
+
// entero: `.env` no aparece en «el .env.example», y un punto sólo lo cierra si termina la frase.
|
|
52
83
|
function mentions(text, item) {
|
|
53
84
|
const lower = String(text).toLowerCase()
|
|
54
85
|
const found = []
|
|
@@ -59,10 +90,34 @@ function mentions(text, item) {
|
|
|
59
90
|
const rest = lower.slice(at + name.length)
|
|
60
91
|
if (before && !/[\s'"`(/]/.test(before)) continue
|
|
61
92
|
if (rest && !/^(?:[\s'"`),;:!?]|\.(?:\s|$)|$)/.test(rest)) continue
|
|
62
|
-
|
|
93
|
+
const clause = lower.slice(0, at).split(CLAUSE).pop()
|
|
94
|
+
found.push({ denied: NEGATION.test(clause), asked: asks(`${clause} ${rest.split(CLAUSE)[0]}`) })
|
|
63
95
|
}
|
|
64
96
|
}
|
|
65
|
-
return { named: found.
|
|
97
|
+
return { named: found.some((one) => one.asked && !one.denied), denied: found.some((one) => one.denied) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Una orden de publicar se lee aparte, porque `mentions` compara también el basename: para el ítem
|
|
101
|
+
// `push origin feat/login` eso es `login`, y «arreglá el login y no subas nada» publicaba (caso 103). Acá
|
|
102
|
+
// el remoto y la rama tienen que aparecer tal cual, como palabras enteras, en una frase que pida publicar
|
|
103
|
+
// —un verbo de publicar, no cualquiera: «revisá feat/x en origin» no pide un push— y sin una negación
|
|
104
|
+
// antes del último de los dos.
|
|
105
|
+
const PUSHES = new Set('subi sube subir pushea pushear push publica publicar publish empuja empujar'.split(' '))
|
|
106
|
+
function ordersPush(text, item) {
|
|
107
|
+
const [verb, remote, branch] = item.split(' ')
|
|
108
|
+
if (verb !== 'push' || !remote || !branch) return false
|
|
109
|
+
return String(text).split(CLAUSE).some((clause) => {
|
|
110
|
+
// La comilla simple se saca de los bordes y no se corta en ella: partida, «don't» dejaba de ser una
|
|
111
|
+
// negación.
|
|
112
|
+
const words = clause.split(/[\s"`()]+/)
|
|
113
|
+
.map((word) => word.replace(/^'+/, '').replace(/\.$/, '').replace(/'+$/, ''))
|
|
114
|
+
const last = Math.max(words.indexOf(remote), words.indexOf(branch))
|
|
115
|
+
if (words.indexOf(remote) < 0 || words.indexOf(branch) < 0) return false
|
|
116
|
+
const plain = clause.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
117
|
+
.match(/[a-z]+/g) || []
|
|
118
|
+
return plain.some((word) => PUSHES.has(word) || PUSHES.has(word.replace(ENCLITIC, '')))
|
|
119
|
+
&& !NEGATION.test(words.slice(0, last + 1).join(' '))
|
|
120
|
+
})
|
|
66
121
|
}
|
|
67
122
|
|
|
68
123
|
// Quien contesta a un bloqueo que quedó pendiente. Sólo el principio del mensaje: «dale» es la respuesta
|
|
@@ -73,6 +128,10 @@ const YES = new RegExp(String.raw`^\s*(?:s[ií]|dale|ok(?:ay)?|hac[eé]lo|hazlo|
|
|
|
73
128
|
// El hook de mensaje. Nunca frena: un mensaje de la persona no se bloquea, y sin registro los guards
|
|
74
129
|
// deciden como antes. Un texto que empieza con una etiqueta no lo escribió una persona —Claude avisa así
|
|
75
130
|
// que terminó un subagente, con `<task-notification>`—, y en CI no hay persona.
|
|
131
|
+
//
|
|
132
|
+
// Lo concedido es lo único que cruza de un mensaje al siguiente, y se hereda aunque este mensaje no lo
|
|
133
|
+
// haya escrito una persona: un aviso del runner en el medio no le quita a nadie lo que ya autorizó. La
|
|
134
|
+
// negación se aplica venga de donde venga, porque revocar es la dirección segura.
|
|
76
135
|
function record(input) {
|
|
77
136
|
try {
|
|
78
137
|
if (!input.session_id) return
|
|
@@ -82,9 +141,10 @@ function record(input) {
|
|
|
82
141
|
const approved = human && previous && YES.test(text)
|
|
83
142
|
? previous.pending.filter((item) => !mentions(text, item).denied)
|
|
84
143
|
: []
|
|
144
|
+
const granted = previous ? (previous.granted || []).filter((one) => !mentions(text, one).denied) : []
|
|
85
145
|
fs.mkdirSync(DIR, { recursive: true })
|
|
86
|
-
fs.writeFileSync(recordPath(input.session_id),
|
|
87
|
-
|
|
146
|
+
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(
|
|
147
|
+
{ id: idOf(input), text, human, flow: flowCommand(text), approved, granted, pending: [] }))
|
|
88
148
|
} catch { /* registrar es un extra: si falla, los guards siguen frenando lo que frenaban */ }
|
|
89
149
|
}
|
|
90
150
|
|
|
@@ -99,12 +159,63 @@ function said(input) {
|
|
|
99
159
|
return current && saved.id && current !== saved.id ? null : saved
|
|
100
160
|
}
|
|
101
161
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
162
|
+
// Con qué autorización pasa un ítem, o vacío si no pasa: lo pidió este mensaje, un «dale» aprobó lo que
|
|
163
|
+
// había quedado frenado, o se lo concedieron antes en esta sesión. Qué cuenta como pedirlo depende de qué
|
|
164
|
+
// se frena: un archivo se nombra, un push se ordena con su remoto y su rama.
|
|
165
|
+
const named = (text, item) => mentions(text, item).named
|
|
166
|
+
function why(saved, item, asked, inherit) {
|
|
167
|
+
if (asked(saved.text, item)) return 'orden'
|
|
168
|
+
if (saved.approved.includes(item)) return 'dale'
|
|
169
|
+
if (!inherit) return ''
|
|
170
|
+
return (saved.granted || []).includes(item) ? 'concedido' : ''
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Lo que un guard dejó pasar queda anotado, que es la contracara de `hold`: hasta 0.82.0 sólo se anotaba
|
|
174
|
+
// lo frenado, así que una autorización usada moría con el mensaje y lo mismo se frenaba una y otra vez.
|
|
175
|
+
// Ahí la salida barata era apagar el guard para toda la sesión con una variable, o sea el permiso más
|
|
176
|
+
// ancho de los dos (caso 116).
|
|
177
|
+
//
|
|
178
|
+
// Se anota el ítem **como el guard lo nombró** —la ruta en la forma que ese guard tiene a mano— y no el
|
|
179
|
+
// archivo que hay detrás: es el mismo alcance que tiene una línea de `.ops-approval`, angosto de más
|
|
180
|
+
// antes que de menos.
|
|
181
|
+
function grant(input, saved, items) {
|
|
182
|
+
const before = saved.granted || []
|
|
183
|
+
const granted = [...new Set([...before, ...items])]
|
|
184
|
+
if (granted.length === before.length) return
|
|
185
|
+
try {
|
|
186
|
+
saved.granted = granted
|
|
187
|
+
fs.writeFileSync(recordPath(input.session_id), JSON.stringify(saved))
|
|
188
|
+
} catch { /* sin anotarlo, se vuelve a pedir */ }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Con qué autorización pasa cada uno de los que pasan. Lo pregunta quien necesita el porqué y no sólo el
|
|
192
|
+
// qué —el rastro de un push lo anota (caso 112)—, y no concede nada: preguntar no cambia qué va a valer
|
|
193
|
+
// en el mensaje siguiente.
|
|
194
|
+
//
|
|
195
|
+
// Conceder y heredar son dos cosas distintas y hasta 0.83.0 se movían juntas: conceder es escribir en el
|
|
196
|
+
// registro, heredar es leer lo que escribió un mensaje anterior. Quien decide algo que vuelve a tener
|
|
197
|
+
// consecuencia cada vez que ocurre apaga lo segundo con `inherit: false`, y ahí vale lo que la persona pidió
|
|
198
|
+
// en el mensaje en curso o aprobó con un «dale» (caso 119).
|
|
199
|
+
function authorized(input, items, { asked = named, inherit = true } = {}) {
|
|
200
|
+
const saved = said(input)
|
|
201
|
+
if (!saved) return []
|
|
202
|
+
return items.map((item) => ({ item, via: why(saved, item, asked, inherit) })).filter((one) => one.via)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Lo que la persona no autorizó de lo que un guard está por frenar; lo que sí, queda concedido.
|
|
104
206
|
function unauthorized(input, items) {
|
|
105
207
|
const saved = said(input)
|
|
106
208
|
if (!saved) return items
|
|
107
|
-
|
|
209
|
+
const passed = items.filter((item) => why(saved, item, named, true))
|
|
210
|
+
grant(input, saved, passed)
|
|
211
|
+
return items.filter((item) => !passed.includes(item))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Lo mismo sin conceder y sin heredar: lo que no está en el mensaje en curso queda pendiente aunque la
|
|
215
|
+
// sesión lo haya dejado pasar antes.
|
|
216
|
+
function unauthorizedNow(input, items) {
|
|
217
|
+
const cleared = new Set(authorized(input, items, { inherit: false }).map((one) => one.item))
|
|
218
|
+
return items.filter((item) => !cleared.has(item))
|
|
108
219
|
}
|
|
109
220
|
|
|
110
221
|
// Lo que quedó frenado, para que un «dale» en el mensaje siguiente apruebe exactamente eso y nada más.
|
|
@@ -119,4 +230,4 @@ function hold(input, items) {
|
|
|
119
230
|
} catch { return false }
|
|
120
231
|
}
|
|
121
232
|
|
|
122
|
-
module.exports = { DIR, record, said, unauthorized, hold }
|
|
233
|
+
module.exports = { DIR, record, said, authorized, unauthorized, unauthorizedNow, hold, ordersPush }
|
package/engine/hooks/input.js
CHANGED
|
@@ -183,9 +183,6 @@ function stagedFiles(dir) {
|
|
|
183
183
|
return result.stdout.trim().split('\n').filter(Boolean)
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// R10 pide «la autorización configurada para el proyecto» y `runner.allowPush` es esa configuración:
|
|
187
|
-
// sin esto era un interruptor que nadie leía, y un cargo que lo leyó dio por imposible un push que el
|
|
188
|
-
// guard bloqueaba igual. Sin raíz legible no hay permiso que verificar, así que no se autoriza.
|
|
189
186
|
// El índice que un hook de pre-ejecución lee es el de **antes** del comando, y el comando puede ser
|
|
190
187
|
// justamente el que lo llene. Ahí los tres guards que juzgan mirando el índice no fallan: leen bien,
|
|
191
188
|
// encuentran cero archivos y concluyen que no hay nada que revisar.
|
|
@@ -212,13 +209,6 @@ function stagedForCommit(command, cwd) {
|
|
|
212
209
|
return { dir, staged: stagedFiles(dir) }
|
|
213
210
|
}
|
|
214
211
|
|
|
215
|
-
function pushAllowed(input) {
|
|
216
|
-
const root = findOpsRoot(process.env.OPS_ROOT || process.env.CLAUDE_PROJECT_DIR || cwdOf(input))
|
|
217
|
-
if (!root) return false
|
|
218
|
-
const runner = configOf(root).runner
|
|
219
|
-
return Boolean(runner && runner.allowPush === true)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
212
|
function findOpsRoot(start) {
|
|
223
213
|
let current = path.resolve(start)
|
|
224
214
|
while (true) {
|
|
@@ -270,7 +260,7 @@ function opsRoot(input) {
|
|
|
270
260
|
|
|
271
261
|
module.exports = {
|
|
272
262
|
readInput, commandOf, patchOf, filesOf, contentOf, cwdOf, block, configOf,
|
|
273
|
-
gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
|
|
263
|
+
gitDirectory, isCommit, withoutGitGlobals, stagedFiles, stagedForCommit,
|
|
274
264
|
findOpsRoot, opsRoot,
|
|
275
265
|
writableRoots, outsideRoots, DECLARE_IT, unquoted,
|
|
276
266
|
}
|