Semana 11 — Revisión de código y análisis estático
Objetivos
- Explicar para qué sirve la revisión de código además de encontrar errores (comprensión, consistencia, conocimiento compartido) y qué estándar usa quien aprueba un cambio.
- Revisar un pull request en orden (diseño, funcionalidad, pruebas, complejidad, nombres) y escribir comentarios útiles, con su severidad marcada.
- Explicar por qué todo análisis estático es una aproximación, qué son los falsos positivos y los falsos negativos, y qué hace que un equipo confíe en una herramienta.
- Endurecer la configuración de TypeScript y de ESLint en un proyecto existente y decidir, regla por regla, qué se corrige, qué se configura y qué se desactiva.
- Revisar código escrito por una herramienta de IA sabiendo dónde suele fallar, y combinar la lectura humana con las herramientas automáticas.
Sesión A — Revisión de código
Lecturas:
- Software Engineering at Google (SWEG), cap. 9, Code Review.
- Google Engineering Practices, How to do a code review, en especial The Standard of Code Review.
Lecturas complementarias:
- Caitlin Sadowski y otros, Modern Code Review: A Case Study at Google (ICSE SEIP 2018).
- Google Engineering Practices, The CL author’s guide y Small CLs.
- Conventional Comments: etiquetas para la intención de cada comentario.
- GitHub, About pull request reviews; SWEG, cap. 19, Critique, la herramienta de revisión de Google.
Temas:
- Para qué se revisa (SWEG cap. 9): que el código sea correcto, sí, pero también que otra persona lo entienda (si quien revisa no lo entiende, quien lo mantenga tampoco), que sea consistente con el resto de la base de código, y que el conocimiento se reparta. El estudio de Sadowski y otros en Google encontró que, además de evitar errores, quienes desarrollan valoran la revisión sobre todo por lo que enseña y por cómo mantiene las normas del equipo.
- En Google todo cambio necesita la aprobación (LGTM) de otra persona, la de un owner del código (OWNERS) y la de alguien con readability en el lenguaje. En GitHub el equivalente son la revisión obligatoria antes del merge,
CODEOWNERSy las reglas de protección de la ramamain. - El estándar para aprobar (Google eng-practices): se aprueba cuando el cambio mejora con seguridad la salud general del código, aunque no sea perfecto. No existe el código perfecto, solo el código mejor. Los hechos técnicos y los datos pesan más que las opiniones y las preferencias; en estilo, manda la guía de estilo.
- Qué mirar, de lo grande a lo chico: ¿el cambio debe existir y está en el lugar correcto (diseño)? ¿Hace lo que dice, también en los casos límite y con entradas malas (funcionalidad)? ¿Hay pruebas que fallarían si el código estuviera mal? ¿Es más complejo de lo que hace falta? Nombres, comentarios que explican el porqué, documentación. El formato lo revisa el linter, no las personas.
- Cambios pequeños: un pull request de unas 100 líneas se revisa bien; uno de 1000 se aprueba sin leer. Quien escribe el cambio lo divide, escribe una descripción que dice qué cambia y por qué, y se revisa a sí mismo antes de pedir revisión. Quien revisa responde rápido (Google pide dentro de un día hábil), porque un pull request esperando bloquea a alguien.
- Comentarios útiles: sobre el código, no sobre la persona; con el porqué; con una sugerencia concreta cuando se puede; y con la severidad marcada:
Nit:en Google, o las etiquetas de Conventional Comments (issue (blocking):,suggestion (non-blocking):,question:,praise:). Si un desacuerdo no se resuelve en dos idas y vueltas, se conversa en persona o en una llamada. - Error común: el LGTM de cortesía, aprobar sin haber corrido ni entendido el cambio. Otro: discutir de estilo (comillas, espacios) lo que una herramienta podría decidir, mientras pasa sin comentarios el
ifque tiene la condición al revés. - Con IA: hay herramientas que comentan un pull request automáticamente. Sirven como un revisor más, que encuentra ciertos errores y produce también comentarios equivocados o irrelevantes; no reemplazan la aprobación de una persona que entiende el cambio y responde por él.
Sesión B — Análisis estático, tipos y linters · Revisar código escrito por IA
Lecturas:
- SWEG, cap. 20, Static Analysis.
- typescript-eslint, Linting with Type Information y Shared Configs.
- TypeScript,
noUncheckedIndexedAccessyexactOptionalPropertyTypes.
Lecturas complementarias:
- SWEG, cap. 8, Style Guides and Rules.
- University of Maryland, CMSC 398Z Coding with AI: el curso completo trata de cómo revisar y verificar código generado.
Temas:
- Análisis estático: razonar sobre el código sin ejecutarlo. De menos a más profundo: el formateador, el linter (patrones locales sospechosos), el type checker, y los analizadores de flujo de datos (por ejemplo, seguir un texto del usuario hasta una consulta SQL). Por el teorema de Rice, ninguna herramienta puede decidir con exactitud una propiedad no trivial del comportamiento de un programa: todas aproximan, con falsos positivos (avisa algo que no es un error) o falsos negativos (no avisa un error), y casi siempre las dos cosas.
- Lo que Google aprendió con Tricorder (SWEG cap. 20): lo que importa es la tasa de falsos positivos efectivos, los avisos con los que quien desarrolla no hace nada, sea cual sea el motivo. Si es alta, se ignoran todos. Por eso: pocas reglas que valen la pena, resultados en la revisión de código (donde se puede actuar) y no en un reporte aparte, una corrección sugerida cuando se puede, y un botón de “no es útil” que llega a quien mantiene la regla.
- TypeScript como análisis estático:
strict: trueactiva, entre otras,strictNullChecksynoImplicitAny. Hay opciones más estrictas fuera destrict: connoUncheckedIndexedAccess,items[0]esT | undefinedporque la lista puede estar vacía, y el compilador obliga a manejar ese caso; conexactOptionalPropertyTypes, una propiedad opcionalbody?: stringno aceptaundefinedexplícito. - Linting con tipos:
tseslint.configs.recommendedTypeCheckedystrictTypeCheckedusan la información de tipos de TypeScript (parserOptions.projectService) y encuentran lo que un linter sintáctico no ve: promesas que nadie espera (no-floating-promises), funcionesasyncdonde se espera una que no devuelve nada (no-misused-promises), unanyque se propaga (no-unsafe-*), condiciones que según los tipos siempre son iguales (no-unnecessary-condition). Cuesta más tiempo de análisis y produce falsos positivos con algunas bibliotecas: ennode:test,test()ydescribe()devuelven promesas que el runner ya maneja. - Las fronteras del programa: lo que llega de afuera (un cuerpo JSON, una fila de la base de datos) se tipa como
unknowny se revisa antes de usarlo; unaso un!le dice al compilador “confía en mí” sin comprobar nada. Cada uno es un lugar donde el tipo puede mentir. - Error común: silenciar el aviso en vez de entenderlo: un
!, unas, un// eslint-disable-next-linesin explicación. Y el otro extremo: activar de golpe cien reglas en un proyecto existente. Se adopta por partes, regla por regla, corrigiendo o justificando cada desactivación por escrito. - Revisar código escrito por IA. Falla de maneras propias: código plausible que no hace lo pedido; funciones, métodos o paquetes que no existen; cambios fuera del alcance de la tarea (reformatear archivos, renombrar); pruebas cambiadas o borradas para que pasen; consultas armadas con texto; un estilo distinto del resto del proyecto; diffs grandes. Qué hacer: pedir cambios chicos; que compile y pase
make checkantes de leer; leer primero las pruebas; comprobar en la documentación cada API que no conocen; revisar cada dependencia nueva (semana 12). Las herramientas estáticas son el primer filtro: un método inventado no pasatsc. - La responsabilidad no cambia: quien abre el pull request responde por todo el código, lo haya escrito a mano o con un agente, y tiene que poder explicarlo en la revisión. Si no lo puede explicar, el pull request no está listo.
Laboratorio — Revisar pull requests de otro equipo; TypeScript estricto y ESLint
Referencias: The Standard of Code Review, Conventional Comments, typed linting. Material: code/week-11/. Individual en los pasos 1 y 2, en parejas desde el paso 3.
- Revisión entre equipos: el docente asigna a cada estudiante dos pull requests abiertos del proyecto de otro equipo. Para cada uno:
gh pr checkout <número> -R <owner>/<repositorio>dentro de una copia del repositorio,make check, leer la descripción y el diff en orden (diseño, funcionalidad, pruebas, complejidad, nombres), y dejar en GitHub al menos tres comentarios en líneas concretas con etiquetas de Conventional Comments y una decisión final (aprobar o pedir cambios) que se justifique con el estándar de Google. - Un pull request como los que propone una herramienta de IA:
code/week-11/pr-generado.diff, escrito para la clase, trae la tarea, la descripción que lo acompaña y el diff. Primero, sin correr nada, escribir enrevision.mdlos comentarios que dejarían. Después aplicarlo en una rama de la copia limpia de Kallpa de la semana 10 (git switch -c pr-generado main,git apply /work/code/week-11/pr-generado.diff) y corrermake -k check(con-k,makesigue con el linter y las pruebas aunquetscfalle). Comparar: ¿qué encontraron las herramientas que ustedes no vieron, y qué vieron ustedes que ninguna herramienta reporta? - En su repositorio de Kallpa, en una rama nueva: agregar
"noUncheckedIndexedAccess": trueentsconfig.jsony corrermake typecheck. Corregir cada error con una guarda, un valor por omisión o un cambio de estructura, sin!nias, y sin cambiar el comportamiento: en este paso solo cambian los tipos (make testtiene que seguir igual). Luego lo mismo con"exactOptionalPropertyTypes": true. - Linting con tipos: en
eslint.config.js, reemplazartseslint.configs.recommendedportseslint.configs.strictTypeChecked, agregar un bloque conlanguageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } }, y otro confiles: ['**/*.js'], extends: [tseslint.configs.disableTypeChecked]para los archivos que no revisatsc. Corrermake linty agrupar los errores por regla:eslint . -f json | jq -r '.[].messages[].ruleId' | sort | uniq -c | sort -rn. - Decidir regla por regla, con su pareja: corregir el código, configurar la regla (por ejemplo, solo para
test/) o desactivarla. Corregir todos los desrc/. Para las pruebas, discutir qué hacer conno-floating-promisessobretest()ydescribe()denode:test, y con elanydetest/helpers.ts. Cada regla desactivada o configurada lleva un comentario con el porqué. - Dejar
make checken verde y abrir un pull request con los cambios de configuración y las correcciones, con una tabla en la descripción: regla, cuántos avisos dio, qué se decidió y por qué. Lo revisa la otra pareja con las mismas etiquetas del paso 1.
Entregas
- P3 entrega: Segundo sprint, calidad: funcionalidades con pruebas unitarias y de propiedades, cobertura mínima, revisión de todos los pull requests y análisis estático, y la demo del sprint 2 (
projects/p3-calidad/README.md). - P4 publicado: Tercer sprint, entrega continua: pipeline de CI/CD, despliegue, observabilidad, revisión de seguridad, el postmortem de un incidente y la demo del sprint 3 (
projects/p4-entrega-continua/README.md). Entrega en semana 14.
Preguntas de repaso
- Según el estándar de Google, ¿cuándo se aprueba un pull request que todavía tiene cosas que mejorar? ¿Qué se hace con esas cosas?
- Reescriban este comentario de revisión para que sea útil: “esto está mal, así no se hace”. Usen una etiqueta de Conventional Comments y digan si bloquea el merge.
- ¿Por qué ninguna herramienta de análisis estático puede ser a la vez completa y sin falsos positivos? ¿Qué es un falso positivo efectivo y por qué importa más que el falso positivo “técnico”?
- Con
noUncheckedIndexedAccess,const first = bookings[0]tiene tipoBooking | undefined. Den dos formas de corregir el error que da usarfirst.date, y digan por québookings[0]!no es una de ellas. - Un pull request de 900 líneas, escrito con un agente, agrega una funcionalidad y “de paso” reformatea cinco archivos y actualiza dos pruebas que fallaban. Las pruebas pasan. ¿Qué piden antes de revisarlo y qué miran primero cuando lo revisan?
Código de la semana
Compilar y probar dentro del contenedor del curso con make y make test.
Ejemplos (14)Pruebas (4)Compilación (3)
Ejemplos
Los programas que usan los apuntes y la clase.
Ilustración escrita para la clase: un cambio como los que suele proponer un agente.
No es la salida de ninguna herramienta concreta.
Tarea: "Agreguen GET /api/bookings/search?team=texto: reservas cuyo nombre de equipo contiene el texto."
Descripción del pull request: "Agrega la búsqueda de reservas por equipo, con su prueba. make check pasa."
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..521f3ab 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -152,6 +152,14 @@ export function memberBookings(deps: AppDependencies): Handler {
};
}
+// GET /api/bookings/search?team=texto
+export function searchBookings(deps: AppDependencies): Handler {
+ return ({ res, url }) => {
+ const team = url.searchParams.get('team') ?? '';
+ sendJson(res, 200, deps.service.bookingsByTeam(team).map(toBookingJson));
+ };
+}
+
// GET /api/reports/daily?date=YYYY-MM-DD: la caja del día, para la administración.
export function dailyReport(deps: AppDependencies): Handler {
return ({ res, url }) => {
diff --git a/src/http/server.ts b/src/http/server.ts
index 251064b..e91bf20 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -11,6 +11,7 @@ import {
listCourts,
listMembers,
memberBookings,
+ searchBookings,
type AppDependencies,
} from './handlers.ts';
import { HttpError, sendError } from './json.ts';
@@ -30,6 +31,7 @@ export function buildRouter(deps: AppDependencies): Router {
router.get('/api/courts/:id/availability', courtAvailability(deps));
router.get('/api/members', listMembers(deps));
router.get('/api/members/:id/bookings', memberBookings(deps));
+ router.get('/api/bookings/search', searchBookings(deps));
router.post('/api/bookings', createBooking(deps));
router.post('/api/bookings/:id/cancel', cancelBooking(deps));
router.get('/api/reports/daily', dailyReport(deps));
diff --git a/src/services/booking-service.ts b/src/services/booking-service.ts
index 55e62cf..434040f 100644
--- a/src/services/booking-service.ts
+++ b/src/services/booking-service.ts
@@ -45,6 +45,10 @@ export class BookingService {
return this.#bookings.listByMember(memberId);
}
+ bookingsByTeam(team: string): BookingRow[] {
+ return this.#bookings.searchByTeam(team);
+ }
+
findBooking(id: number): BookingRow | undefined {
return this.#bookings.findById(id);
}
diff --git a/src/store/booking-repository.ts b/src/store/booking-repository.ts
index 2e41104..7be3d13 100644
--- a/src/store/booking-repository.ts
+++ b/src/store/booking-repository.ts
@@ -45,6 +45,12 @@ export class BookingRepository {
.all(memberId) as BookingRow[];
}
+ searchByTeam(team: string): BookingRow[] {
+ return this.#db
+ .prepare(`SELECT * FROM bookings WHERE team_name LIKE '%${team}%' ORDER BY date, start_hour`)
+ .all() as BookingRow[];
+ }
+
insert(row: NewBookingRow): number {
const result = this.#db
.prepare(
diff --git a/test/search.test.ts b/test/search.test.ts
new file mode 100644
index 0000000..f908688
--- /dev/null
+++ b/test/search.test.ts
@@ -0,0 +1,18 @@
+import assert from 'node:assert/strict';
+import { after, before, test } from 'node:test';
+import { getJson, startTestServer, type TestServer } from './helpers.ts';
+
+let server: TestServer;
+
+before(async () => {
+ server = await startTestServer();
+});
+
+after(async () => {
+ await server.close();
+});
+
+test('busca reservas por equipo', async () => {
+ const { status } = await getJson(`${server.url}/api/bookings/search?team=Pumas`);
+ assert.equal(status, 200);
+});Lo que queda después de la revisión de código; se aplica sobre busqueda-agente.diff.
La consulta usa un parámetro, el texto vacío se rechaza y las pruebas revisan el resultado.
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 521f3ab..6712b25 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -155,7 +155,8 @@ export function memberBookings(deps: AppDependencies): Handler {
// GET /api/bookings/search?team=texto
export function searchBookings(deps: AppDependencies): Handler {
return ({ res, url }) => {
- const team = url.searchParams.get('team') ?? '';
+ const team = url.searchParams.get('team')?.trim();
+ if (!team) throw new HttpError(400, 'VALIDATION_ERROR', 'Indiquen el texto a buscar (?team=Pumas).');
sendJson(res, 200, deps.service.bookingsByTeam(team).map(toBookingJson));
};
}
diff --git a/src/store/booking-repository.ts b/src/store/booking-repository.ts
index 7be3d13..b35f057 100644
--- a/src/store/booking-repository.ts
+++ b/src/store/booking-repository.ts
@@ -47,8 +47,8 @@ export class BookingRepository {
searchByTeam(team: string): BookingRow[] {
return this.#db
- .prepare(`SELECT * FROM bookings WHERE team_name LIKE '%${team}%' ORDER BY date, start_hour`)
- .all() as BookingRow[];
+ .prepare("SELECT * FROM bookings WHERE team_name LIKE '%' || ? || '%' ORDER BY date, start_hour")
+ .all(team) as BookingRow[];
}
insert(row: NewBookingRow): number {
diff --git a/test/search.test.ts b/test/search.test.ts
index f908688..4738df6 100644
--- a/test/search.test.ts
+++ b/test/search.test.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
-import { getJson, startTestServer, type TestServer } from './helpers.ts';
+import { getJson, postJson, startTestServer, type TestServer } from './helpers.ts';
let server: TestServer;
@@ -12,7 +12,35 @@ after(async () => {
await server.close();
});
-test('busca reservas por equipo', async () => {
- const { status } = await getJson(`${server.url}/api/bookings/search?team=Pumas`);
+const booking = { courtId: 1, memberId: 1, date: '2026-03-10', startHour: 8, hours: 1 };
+
+async function search(team: string) {
+ return getJson(`${server.url}/api/bookings/search?team=${encodeURIComponent(team)}`);
+}
+
+test('encuentra solo las reservas de los equipos que coinciden', async () => {
+ await postJson(`${server.url}/api/bookings`, { ...booking, teamName: 'Los Pumas de Comas' });
+ await postJson(`${server.url}/api/bookings`, { ...booking, startHour: 9, teamName: "O'Higgins de Ate" });
+
+ const { status, body } = await search('Pumas');
+ assert.equal(status, 200);
+ assert.deepEqual(body.map((b: { teamName: string }) => b.teamName), ['Los Pumas de Comas']);
+});
+
+test('un apóstrofe en el texto es un carácter más', async () => {
+ const { status, body } = await search("O'Higgins");
assert.equal(status, 200);
+ assert.deepEqual(body.map((b: { teamName: string }) => b.teamName), ["O'Higgins de Ate"]);
+});
+
+test('el texto no puede cambiar la consulta', async () => {
+ const { status, body } = await search("' OR '1'='1");
+ assert.equal(status, 200);
+ assert.deepEqual(body, []);
+});
+
+test('pide el texto a buscar', async () => {
+ const { status, body } = await search('');
+ assert.equal(status, 400);
+ assert.equal(body.error.code, 'VALIDATION_ERROR');
});// Las mismas reglas que Kallpa: las recomendadas de JavaScript y de
// typescript-eslint, sin información de tipos.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig(
{ ignores: ['build/', 'solution/', 'ejemplos/invented-method.ts'] },
js.configs.recommended,
tseslint.configs.recommended,
);// La configuración del paso 4 del laboratorio, para medir Kallpa o los
// ejemplos: strictTypeChecked, con información de tipos, sin ajustar ninguna regla.
import process from 'node:process';
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig(
{ ignores: ['build/', 'solution/', 'data/', 'reports/', 'coverage/', '.stryker-tmp/', 'ejemplos/invented-method.ts'] },
js.configs.recommended,
tseslint.configs.strictTypeChecked,
{ languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: process.cwd() } } },
{ files: ['**/*.js'], extends: [tseslint.configs.disableTypeChecked] },
{
files: ['public/**/*.js'],
languageOptions: {
sourceType: 'module',
globals: { document: 'readonly', window: 'readonly', fetch: 'readonly', console: 'readonly' },
},
},
);// Encuentra los lugares donde el tipo puede mentir: cada `as` (menos `as const`)
// y cada `!`. Mira el árbol sintáctico: un grep confunde `as` con otras palabras.
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig({
files: ['**/*.ts'],
languageOptions: { parser: tseslint.parser },
linterOptions: { reportUnusedDisableDirectives: 'off' },
rules: {
'no-restricted-syntax': [
'error',
{ selector: "TSAsExpression:not([typeAnnotation.typeName.name='const'])", message: 'as' },
{ selector: 'TSNonNullExpression', message: 'no-null' },
],
},
});// Un analizador de una sola regla: avisa cuando prepare() recibe un template string
// con ${...}. Mira la forma del código, no a dónde llega el dato; por eso tiene
// falsos positivos y falsos negativos (ver el apunte).
import tseslint from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig({
files: ['**/*.ts'],
languageOptions: { parser: tseslint.parser },
linterOptions: { reportUnusedDisableDirectives: 'off' },
rules: {
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.property.name='prepare'] > TemplateLiteral[expressions.length>0]",
message: 'SQL armado con un template: usen parámetros (?).',
},
],
},
});Ilustración escrita para la clase: un pull request como los que suele proponer
un agente. No es la salida de ninguna herramienta concreta.
Laboratorio de la semana 11, paso 2. Lean todo esto antes de correr nada, y escriban sus
comentarios en revision.md. Después:
git switch -c pr-generado main
git apply /work/code/week-11/pr-generado.diff
make -k check
TAREA
"En GET /api/members/:id/bookings, agreguen un parámetro ?status= para filtrar
las reservas de un vecino por estado (confirmed o cancelled). Sin el parámetro,
responde como hasta ahora."
DESCRIPCIÓN DEL PULL REQUEST
Agrega el filtro ?status= a las reservas de un vecino.
Cambio mínimo: solo toca lo necesario para el filtro. El parámetro queda
validado y la consulta es segura. Agregué una prueba que cubre el filtro por
completo y ajusté las pruebas existentes. Corrí las pruebas: todas pasan.
DIFF
diff --git a/src/http/handlers.ts b/src/http/handlers.ts
index 4f1354a..020dfd4 100644
--- a/src/http/handlers.ts
+++ b/src/http/handlers.ts
@@ -138,17 +138,18 @@ export function cancelBooking(deps: AppDependencies): Handler {
if (!isCancellableAt({ date: row.date, startHour: row.start_hour }, now)) {
throw new HttpError(422, 'TOO_LATE_TO_CANCEL', 'Las reservas se cancelan con al menos 3 horas de anticipación.');
}
- deps.service.markCancelled(row.id, deps.clock.now().toISOString());
+ deps.service.setCancelled(row.id, deps.clock.now().toISOString());
sendJson(res, 200, { booking: toBookingJson(deps.service.findBooking(row.id)!) });
};
}
-// GET /api/members/:id/bookings
+// GET /api/members/:id/bookings?status=confirmed|cancelled
export function memberBookings(deps: AppDependencies): Handler {
- return ({ res, params }) => {
+ return ({ res, url, params }) => {
const member = deps.service.findMember(Number(params.id));
if (!member) throw new HttpError(404, 'MEMBER_NOT_FOUND', `No existe el miembro ${params.id}.`);
- sendJson(res, 200, deps.service.bookingsForMember(member.id).map(toBookingJson));
+ const status = url.searchParams.get('status') ?? undefined;
+ sendJson(res, 200, deps.service.bookingsForMember(member.id, status).map(toBookingJson));
};
}
diff --git a/src/services/booking-service.ts b/src/services/booking-service.ts
index 55e62cf..1dbf372 100644
--- a/src/services/booking-service.ts
+++ b/src/services/booking-service.ts
@@ -41,8 +41,8 @@ export class BookingService {
return this.#bookings.listByDate(date);
}
- bookingsForMember(memberId: number): BookingRow[] {
- return this.#bookings.listByMember(memberId);
+ bookingsForMember(memberId: number, status?: string): BookingRow[] {
+ return this.#bookings.findAllForMember(memberId, status);
}
findBooking(id: number): BookingRow | undefined {
@@ -53,7 +53,7 @@ export class BookingService {
return this.#bookings.insert(row);
}
- markCancelled(id: number, cancelledAt: string): void {
+ setCancelled(id: number, cancelledAt: string): void {
this.#bookings.updateStatus(id, 'cancelled', cancelledAt);
}
}
diff --git a/src/store/booking-repository.ts b/src/store/booking-repository.ts
index 2e41104..cd5f2b7 100644
--- a/src/store/booking-repository.ts
+++ b/src/store/booking-repository.ts
@@ -39,7 +39,12 @@ export class BookingRepository {
return this.#db.prepare('SELECT * FROM bookings WHERE date = ? ORDER BY court_id, start_hour').all(date) as BookingRow[];
}
- listByMember(memberId: number): BookingRow[] {
+ findAllForMember(memberId: number, status?: string): BookingRow[] {
+ if (status) {
+ return this.#db
+ .prepare(`SELECT * FROM bookings WHERE member_id = ? AND status = '${status}' ORDER BY date, start_hour`)
+ .allRows(memberId) as BookingRow[];
+ }
return this.#db
.prepare('SELECT * FROM bookings WHERE member_id = ? ORDER BY date, start_hour')
.all(memberId) as BookingRow[];
diff --git a/src/store/court-repository.ts b/src/store/court-repository.ts
index 6968798..89fd398 100644
--- a/src/store/court-repository.ts
+++ b/src/store/court-repository.ts
@@ -1,5 +1,5 @@
-import type { DatabaseSync } from 'node:sqlite';
-import type { Court } from '../domain/court.ts';
+import type { DatabaseSync } from "node:sqlite";
+import type { Court } from "../domain/court.ts";
export class CourtRepository {
#db: DatabaseSync;
@@ -9,15 +9,21 @@ export class CourtRepository {
}
list(): Court[] {
- return this.#db.prepare('SELECT id, name, sport FROM courts ORDER BY name').all() as Court[];
+ return this.#db
+ .prepare("SELECT id, name, sport FROM courts ORDER BY name")
+ .all() as Court[];
}
findById(id: number): Court | undefined {
- return this.#db.prepare('SELECT id, name, sport FROM courts WHERE id = ?').get(id) as Court | undefined;
+ return this.#db
+ .prepare("SELECT id, name, sport FROM courts WHERE id = ?")
+ .get(id) as Court | undefined;
}
insert(court: Omit<Court, 'id'>): number {
- const result = this.#db.prepare('INSERT INTO courts (name, sport) VALUES (?, ?)').run(court.name, court.sport);
+ const result = this.#db
+ .prepare("INSERT INTO courts (name, sport) VALUES (?, ?)")
+ .run(court.name, court.sport);
return Number(result.lastInsertRowid);
}
}
diff --git a/src/store/member-repository.ts b/src/store/member-repository.ts
index 7190d41..df72e51 100644
--- a/src/store/member-repository.ts
+++ b/src/store/member-repository.ts
@@ -1,5 +1,5 @@
-import type { DatabaseSync } from 'node:sqlite';
-import type { Member } from '../domain/member.ts';
+import type { DatabaseSync } from "node:sqlite";
+import type { Member } from "../domain/member.ts";
export class MemberRepository {
#db: DatabaseSync;
@@ -9,16 +9,20 @@ export class MemberRepository {
}
list(): Member[] {
- return this.#db.prepare('SELECT id, name, phone, plan FROM members ORDER BY name').all() as Member[];
+ return this.#db
+ .prepare("SELECT id, name, phone, plan FROM members ORDER BY name")
+ .all() as Member[];
}
findById(id: number): Member | undefined {
- return this.#db.prepare('SELECT id, name, phone, plan FROM members WHERE id = ?').get(id) as Member | undefined;
+ return this.#db
+ .prepare("SELECT id, name, phone, plan FROM members WHERE id = ?")
+ .get(id) as Member | undefined;
}
insert(member: Omit<Member, 'id'>): number {
const result = this.#db
- .prepare('INSERT INTO members (name, phone, plan) VALUES (?, ?, ?)')
+ .prepare("INSERT INTO members (name, phone, plan) VALUES (?, ?, ?)")
.run(member.name, member.phone, member.plan);
return Number(result.lastInsertRowid);
}
diff --git a/test/http.test.ts b/test/http.test.ts
index 2d8743a..7233815 100644
--- a/test/http.test.ts
+++ b/test/http.test.ts
@@ -49,9 +49,10 @@ describe('API de Kallpa', () => {
assert.equal(at(20).teamName, 'Los Pumas de Comas');
});
- test('avisa por WhatsApp a quien reservó', () => {
- assert.equal(server.sent.length, 1);
- assert.equal(server.sent[0].phone, '987654321');
+ test('filtra las reservas de un vecino por estado', async () => {
+ await postJson(`${server.url}/api/bookings`, { ...booking, courtId: 2, teamName: 'Deportivo Rímac' });
+ const { status } = await getJson(`${server.url}/api/members/1/bookings?state=confirmed`);
+ assert.equal(status, 200);
});
test('no deja reservar un horario ocupado', async () => {// Utilidades de las pruebas: correr herramientas y preparar copias de Kallpa.
import { spawnSync } from 'node:child_process';
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
export const HERE = import.meta.dirname;
export const KALLPA = resolve(HERE, '../../kallpa');
export type RunResult = { code: number; output: string };
export function run(command: string, args: string[], cwd: string = HERE): RunResult {
// Sin NODE_TEST_CONTEXT: si no, un node --test hijo no corre sus archivos.
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
const result = spawnSync(command, args, { cwd, env, encoding: 'utf8' });
return { code: result.status ?? -1, output: `${result.stdout}${result.stderr}` };
}
// Una copia nueva de Kallpa en build/<name>, con un commit base.
export function freshKallpa(name: string): string {
const dir = resolve(HERE, 'build', name);
rmSync(dir, { recursive: true, force: true });
mkdirSync(resolve(HERE, 'build'), { recursive: true });
cpSync(KALLPA, dir, { recursive: true });
rmSync(resolve(dir, 'data'), { recursive: true, force: true });
mkdirSync(resolve(dir, 'data'));
const git = gitIn(dir);
git('init', '-q');
git('add', '-A');
git('commit', '-qm', 'Kallpa publicada');
return dir;
}
export function gitIn(dir: string): (...args: string[]) => RunResult {
const base = ['-c', 'user.name=Kallpa', '-c', 'user.email=kallpa@example.com', '-c', 'init.defaultBranch=main'];
return (...args) => run('git', [...base, ...args], dir);
}
export function applyPatch(dir: string, patch: string): RunResult {
return gitIn(dir)('apply', resolve(HERE, patch));
}
export function replaceInFile(path: string, from: string, to: string): void {
const text = readFileSync(path, 'utf8');
if (!text.includes(from)) throw new Error(`No encuentro "${from}" en ${path}.`);
writeFileSync(path, text.replace(from, to));
}
// Cuenta los avisos de un JSON de ESLint por regla.
export function countByRule(json: string): Map<string, number> {
const files = JSON.parse(json) as { messages: { ruleId: string | null }[] }[];
const counts = new Map<string, number>();
for (const file of files) {
for (const message of file.messages) {
const rule = message.ruleId ?? '(sin regla)';
counts.set(rule, (counts.get(rule) ?? 0) + 1);
}
}
return counts;
}// Lo que llega de afuera: con `as` el compilador confía; con unknown obliga a revisar.
export type Draft = { courtId: number; date: string };
export function parseWithCast(text: string): Draft {
return JSON.parse(text) as Draft;
}
export function parseChecked(text: string): Draft {
const value: unknown = JSON.parse(text);
if (typeof value !== 'object' || value === null) throw new Error('Se esperaba un objeto.');
if (!('courtId' in value) || typeof value.courtId !== 'number') throw new Error('Falta courtId.');
if (!('date' in value) || typeof value.date !== 'string') throw new Error('Falta date.');
return { courtId: value.courtId, date: value.date };
}// Dos funciones que `strict` acepta. Con noUncheckedIndexedAccess, el compilador
// avisa en las dos: en una con razón y en la otra no.
type Slot = { date: string };
// Se cae con una lista vacía: sin la opción, un falso negativo.
export function firstDate(slots: readonly Slot[]): string {
return slots[0].date;
}
// Nunca se cae, porque split siempre devuelve al menos un elemento: con la
// opción, un falso positivo.
export function firstPart(text: string): number {
return text.split('-')[0].length;
}// Falla a propósito: StatementSync no tiene getAll. No está en tsconfig.json.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE courts (id INTEGER PRIMARY KEY, name TEXT)');
export const rows = db.prepare('SELECT * FROM courts').getAll();// Sin exactOptionalPropertyTypes, `note?: string` también acepta undefined explícito.
export type Options = { note?: string };
export const missing: Options = {};
export const explicit: Options = { note: undefined };
// Para quien lee el objeto no es lo mismo.
export function keys(options: Options): string[] {
return Object.keys(options);
}// Errores con promesas que tsc y el linter sin tipos dejan pasar.
export async function saveAudit(line: string, saved: string[]): Promise<void> {
await Promise.resolve();
if (line === '') throw new Error('Línea vacía.');
saved.push(line);
}
// forEach no espera a una función async: al volver, todavía no se guardó nada.
export function saveAll(lines: string[], saved: string[]): number {
lines.forEach(async (line) => {
await saveAudit(line, saved);
});
return saved.length;
}
// Nadie espera a saveAudit: un error ahí no pasa por este try/catch.
export function confirm(line: string, saved: string[]): string {
try {
saveAudit(line, saved);
return 'confirmada';
} catch {
return 'falló';
}
}// Cuatro formas de pasar SQL a prepare, para eslint.sql.js.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE bookings (team_name TEXT)');
// 1. Inyección, y la regla la ve.
export function direct(team: string): unknown[] {
return db.prepare(`SELECT * FROM bookings WHERE team_name = '${team}'`).all();
}
// 2. La misma inyección, y la regla no la ve: el template no está dentro de prepare.
export function throughVariable(team: string): unknown[] {
const sql = `SELECT * FROM bookings WHERE team_name = '${team}'`;
return db.prepare(sql).all();
}
// 3. La misma inyección con +, y la regla tampoco la ve.
export function withPlus(team: string): unknown[] {
return db.prepare("SELECT * FROM bookings WHERE team_name = '" + team + "'").all();
}
// 4. Correcto, y la regla avisa: el texto interpolado es una constante.
const TABLE = 'bookings';
export function constantTable(): unknown[] {
return db.prepare(`SELECT COUNT(*) AS n FROM ${TABLE}`).all();
}Pruebas
Comprueban lo que afirman los apuntes; se ejecutan con make test.
// Lo que el apunte dice de tsc y ESLint sobre los archivos de ejemplos/.
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { parseChecked, parseWithCast } from './ejemplos/cast-lie.ts';
import { firstDate, firstPart } from './ejemplos/first-item.ts';
import { keys, explicit, missing } from './ejemplos/optional.ts';
import { confirm, saveAll } from './ejemplos/promises.ts';
import { countByRule, HERE, run } from './tools.ts';
const TSC_BASE = ['--ignoreConfig', '--noEmit', '--strict', '--target', 'es2024', '--module', 'nodenext', '--types', 'node', '--skipLibCheck'];
test('strict acepta los ejemplos y firstDate se cae con una lista vacía', () => {
assert.equal(run('tsc', [...TSC_BASE, 'ejemplos/first-item.ts']).code, 0);
assert.throws(() => firstDate([]), TypeError);
});
test('noUncheckedIndexedAccess avisa en las dos funciones, aunque firstPart no se cae', () => {
const { code, output } = run('tsc', [...TSC_BASE, '--noUncheckedIndexedAccess', 'ejemplos/first-item.ts']);
assert.equal(code, 2);
assert.match(output, /first-item\.ts\(7,10\): error TS2532/);
assert.match(output, /first-item\.ts\(13,10\): error TS2532/);
assert.equal(firstPart(''), 0);
});
test('exactOptionalPropertyTypes rechaza el undefined explícito', () => {
assert.equal(run('tsc', [...TSC_BASE, 'ejemplos/optional.ts']).code, 0);
const { code, output } = run('tsc', [...TSC_BASE, '--exactOptionalPropertyTypes', 'ejemplos/optional.ts']);
assert.equal(code, 2);
assert.match(output, /optional\.ts\(5,14\): error TS2375/);
assert.deepEqual(keys(missing), []);
assert.deepEqual(keys(explicit), ['note']);
// Al mezclar con valores por omisión, el undefined explícito los pisa.
assert.deepEqual({ ...{ note: 'por omisión' }, ...missing }, { note: 'por omisión' });
assert.deepEqual({ ...{ note: 'por omisión' }, ...explicit }, { note: undefined });
assert.equal(JSON.stringify(explicit), JSON.stringify(missing));
});
test('un método inventado no pasa tsc y al correr es un TypeError', () => {
const { code, output } = run('tsc', [...TSC_BASE, 'ejemplos/invented-method.ts']);
assert.equal(code, 2);
assert.match(output, /error TS2339: Property 'getAll' does not exist on type 'StatementSync'/);
const running = run('node', ['ejemplos/invented-method.ts']);
assert.notEqual(running.code, 0);
assert.match(running.output, /TypeError: .*getAll is not a function/);
});
test('el as deja pasar datos que no tienen la forma; la revisión con unknown no', () => {
const text = '{"courtId":"uno"}';
assert.equal(parseWithCast(text).courtId, 'uno'); // el tipo dice number
assert.throws(() => parseChecked(text), /Falta courtId/);
assert.deepEqual(parseChecked('{"courtId":1,"date":"2026-03-10","extra":true}'), { courtId: 1, date: '2026-03-10' });
});
test('tsc sí rechaza una promesa usada como condición', () => {
const dir = resolve(HERE, 'build');
mkdirSync(dir, { recursive: true });
const file = resolve(dir, 'promise-condition.ts');
writeFileSync(file, "declare function isOpen(): Promise<boolean>;\nexport const answer = isOpen() ? 'sí' : 'no';\n");
const { code, output } = run('tsc', [...TSC_BASE, file]);
assert.equal(code, 2);
assert.match(output, /error TS2801/);
});
test('los errores de promesas pasan tsc y solo el linter con tipos los ve', async () => {
assert.equal(run('tsc', [...TSC_BASE, 'ejemplos/promises.ts']).code, 0);
assert.equal(run('eslint', ['ejemplos/promises.ts']).code, 0);
const typed = run('eslint', ['-c', 'eslint.estricto.js', '-f', 'json', 'ejemplos/promises.ts']);
const rules = countByRule(typed.output);
assert.equal(rules.get('@typescript-eslint/no-misused-promises'), 1);
assert.equal(rules.get('@typescript-eslint/no-floating-promises'), 1);
// Lo que pasa al correrlos: saveAll vuelve antes de guardar.
const saved: string[] = [];
assert.equal(saveAll(['a', 'b'], saved), 0);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(saved, ['a', 'b']);
// confirm responde 'confirmada', y el error, que nadie atrapa, termina el proceso.
assert.equal(confirm('hola', saved), 'confirmada');
const crash = run('node', ['--input-type=module', '-e', "import { confirm } from './ejemplos/promises.ts'; console.log(confirm('', []));"]);
assert.match(crash.output, /^confirmada\n/);
assert.match(crash.output, /Error: Línea vacía/);
assert.equal(crash.code, 1);
});
test('la regla de SQL tiene falsos negativos y falsos positivos', () => {
const { code, output } = run('eslint', ['--no-config-lookup', '-c', 'eslint.sql.js', '-f', 'json', 'ejemplos/sql-shapes.ts']);
assert.equal(code, 1);
const files = JSON.parse(output) as { messages: { line: number; ruleId: string }[] }[];
// Avisa en direct (línea 9) y en constantTable (línea 26); no en throughVariable ni en withPlus.
assert.deepEqual(files[0]?.messages.map((m) => m.line), [9, 26]);
});// El ejemplo del apunte: una búsqueda por equipo con SQL armado con una
// template, una prueba que solo mira el 200, y lo que queda después de revisarla.
import assert from 'node:assert/strict';
import { resolve } from 'node:path';
import { after, before, describe, test } from 'node:test';
import { applyPatch, freshKallpa, HERE, replaceInFile, run } from './tools.ts';
type Booking = { teamName: string; date: string; status: string };
type Response = { status: number; body: unknown };
const TEAMS = ['Los Pumas de Comas', 'Real Barranco'];
const UNION = "' UNION SELECT id, 1, 1, name, 7, 1, phone, 0, plan, '', NULL FROM members -- ";
// Levanta la copia de Kallpa de `dir` con los helpers de sus pruebas.
async function withServer(dir: string) {
const helpers = await import(resolve(dir, 'test/helpers.ts')) as typeof import('../../kallpa/test/helpers.ts');
const server = await helpers.startTestServer();
for (const [i, teamName] of TEAMS.entries()) {
await helpers.postJson(`${server.url}/api/bookings`, { courtId: 1, memberId: i + 1, date: '2026-03-10', startHour: 8 + i, hours: 1, teamName });
}
const search = async (team: string): Promise<Response> =>
helpers.getJson(`${server.url}/api/bookings/search?team=${encodeURIComponent(team)}`);
return { server, search };
}
describe('busqueda-agente.diff', () => {
let dir: string;
let app: Awaited<ReturnType<typeof withServer>>;
before(async () => {
dir = freshKallpa('busqueda-agente');
assert.equal(applyPatch(dir, 'busqueda-agente.diff').code, 0);
app = await withServer(dir);
});
after(async () => {
await app.server.close();
});
// Las pruebas por HTTP van primero: `run` bloquea el proceso varios segundos, y un
// socket keep-alive que el servidor cierra mientras tanto daría ECONNRESET al reusarse.
test('funciona con un texto normal', async () => {
const { status, body } = await app.search('Pumas');
assert.equal(status, 200);
assert.deepEqual((body as Booking[]).map((b) => b.teamName), ['Los Pumas de Comas']);
});
test('un apóstrofe en el nombre da 500', async () => {
const { status } = await app.search("O'Higgins");
assert.equal(status, 500);
});
test('la inyección devuelve el nombre y el teléfono de todos los vecinos', async () => {
const { status, body } = await app.search(UNION);
assert.equal(status, 200);
const rows = body as Booking[];
const leaked = rows.filter((row) => row.status === 'regular' || row.status === 'socio');
assert.equal(leaked.length, 10);
assert.ok(leaked.some((row) => row.date === 'Rosa Quispe Mamani' && row.teamName === '987654321'));
});
test('prepare compila solo la primera sentencia: lo que sigue a ; no borra la tabla', async () => {
const { status } = await app.search("'; DROP TABLE bookings; -- ");
assert.equal(status, 200);
const { body } = await app.search('Pumas');
assert.equal((body as Booking[]).length, 1);
});
test('pasa make check completo, también la regla sin tipos', () => {
const { code, output } = run('make', ['check'], dir);
assert.equal(code, 0, output);
assert.match(output, /ℹ pass 41/);
});
test('strictTypeChecked no avisa nada en la línea del SQL', () => {
const { output } = run('eslint', ['--no-config-lookup', '-c', resolve(HERE, 'eslint.estricto.js'), '-f', 'json', 'src/store/booking-repository.ts'], dir);
const files = JSON.parse(output) as { messages: unknown[] }[];
assert.equal(files[0]?.messages.length, 0);
});
test('la regla de SQL sí la ve', () => {
const { code, output } = run('eslint', ['--no-config-lookup', '-c', resolve(HERE, 'eslint.sql.js'), 'src/store/booking-repository.ts'], dir);
assert.equal(code, 1);
assert.match(output, /SQL armado con un template/);
});
test('si se quita el filtro, las pruebas siguen pasando', () => {
const copy = freshKallpa('busqueda-mutante');
assert.equal(applyPatch(copy, 'busqueda-agente.diff').code, 0);
replaceInFile(resolve(copy, 'src/store/booking-repository.ts'), "WHERE team_name LIKE '%${team}%' ", '');
const { code, output } = run('node', ['--test', 'test/search.test.ts'], copy);
assert.equal(code, 0, output);
});
});
describe('busqueda-corregida.diff', () => {
let dir: string;
let app: Awaited<ReturnType<typeof withServer>>;
before(async () => {
dir = freshKallpa('busqueda-corregida');
assert.equal(applyPatch(dir, 'busqueda-agente.diff').code, 0);
assert.equal(applyPatch(dir, 'busqueda-corregida.diff').code, 0);
app = await withServer(dir);
});
after(async () => {
await app.server.close();
});
test('la misma inyección ya no devuelve nada', async () => {
const { status, body } = await app.search(UNION);
assert.equal(status, 200);
assert.deepEqual(body, []);
});
test('un % sigue siendo un comodín de LIKE', async () => {
const { body } = await app.search('%');
assert.equal((body as Booking[]).length, 2);
});
test('pasa make check, con cuatro pruebas de la búsqueda', () => {
const { code, output } = run('make', ['check'], dir);
assert.equal(code, 0, output);
assert.match(output, /ℹ pass 44/);
});
test('si se quita el filtro, las pruebas fallan', () => {
const copy = freshKallpa('busqueda-mutante-2');
assert.equal(applyPatch(copy, 'busqueda-agente.diff').code, 0);
assert.equal(applyPatch(copy, 'busqueda-corregida.diff').code, 0);
replaceInFile(resolve(copy, 'src/store/booking-repository.ts'), "WHERE team_name LIKE '%' || ? || '%' ", '');
replaceInFile(resolve(copy, 'src/store/booking-repository.ts'), '.all(team)', '.all()');
const { code } = run('node', ['--test', 'test/search.test.ts'], copy);
assert.notEqual(code, 0);
});
});// Los números de Kallpa publicada que da el apunte. Si Kallpa cambia, cambian.
import assert from 'node:assert/strict';
import { copyFileSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { countByRule, freshKallpa, HERE, replaceInFile, run } from './tools.ts';
function tscErrors(dir: string, option: string): string[] {
replaceInFile(resolve(dir, 'tsconfig.json'), '"strict": true,', `"strict": true, "${option}": true,`);
return run('tsc', ['-p', '.'], dir).output.split('\n').filter((line) => line.includes(': error TS'));
}
test('noUncheckedIndexedAccess da 9 errores en Kallpa, en 5 archivos', () => {
const errors = tscErrors(freshKallpa('nuia'), 'noUncheckedIndexedAccess');
assert.equal(errors.length, 9);
const files = new Set(errors.map((line) => line.split('(')[0]));
assert.deepEqual([...files].sort(), ['src/domain/calendar.ts', 'src/http/router.ts', 'src/store/database.ts', 'src/store/seed.ts', 'test/http.test.ts']);
});
test('exactOptionalPropertyTypes da 1 error en Kallpa, en test/helpers.ts', () => {
const errors = tscErrors(freshKallpa('eopt'), 'exactOptionalPropertyTypes');
assert.equal(errors.length, 1);
assert.match(errors[0] ?? '', /^test\/helpers\.ts\(59,37\): error TS2379/);
});
test('strictTypeChecked da 105 errores en Kallpa, 26 en src/', () => {
const dir = freshKallpa('typed');
copyFileSync(resolve(HERE, 'eslint.estricto.js'), resolve(dir, 'eslint.config.js'));
const { output } = run('eslint', ['.', '-f', 'json'], dir);
const counts = countByRule(output);
const total = [...counts.values()].reduce((a, b) => a + b, 0);
assert.equal(total, 105);
assert.equal(counts.get('@typescript-eslint/no-floating-promises'), 46);
assert.equal(counts.get('@typescript-eslint/restrict-template-expressions'), 21);
assert.equal(counts.get('@typescript-eslint/no-unsafe-member-access'), 18);
const files = JSON.parse(output) as { filePath: string; messages: unknown[] }[];
const inSrc = files.filter((f) => f.filePath.startsWith(resolve(dir, 'src'))).reduce((n, f) => n + f.messages.length, 0);
assert.equal(inSrc, 26);
rmSync(dir, { recursive: true, force: true });
});
test('no-unnecessary-condition se equivoca en static-files.ts por un tipo optimista', () => {
const dir = freshKallpa('condition');
copyFileSync(resolve(HERE, 'eslint.estricto.js'), resolve(dir, 'eslint.config.js'));
const rule = '@typescript-eslint/no-unnecessary-condition';
const lines = () => {
const { output } = run('eslint', ['src/http/static-files.ts', '-f', 'json'], dir);
const files = JSON.parse(output) as { messages: { ruleId: string }[] }[];
return files[0]?.messages.filter((m) => m.ruleId === rule).length;
};
assert.equal(lines(), 1);
replaceInFile(resolve(dir, 'tsconfig.json'), '"strict": true,', '"strict": true, "noUncheckedIndexedAccess": true,');
assert.equal(lines(), 0);
});
test('allowNumber deja 4 avisos de templates de los 19 de src/, si no se apagan los otros', () => {
const dir = freshKallpa('allownumber');
copyFileSync(resolve(HERE, 'eslint.estricto.js'), resolve(dir, 'eslint.config.js'));
const rule = '@typescript-eslint/restrict-template-expressions';
const inSrc = (options?: object) => {
const extra = options ? ['--rule', JSON.stringify({ [rule]: ['error', options] })] : [];
const { output } = run('eslint', ['src', '-f', 'json', ...extra], dir);
return countByRule(output).get(rule) ?? 0;
};
assert.equal(inSrc(), 19);
// Las opciones que se pasan reemplazan a las de strictTypeChecked, y las demás
// vuelven a su valor por omisión, que es permisivo: undefined también pasa.
assert.equal(inSrc({ allowNumber: true }), 0);
assert.equal(inSrc({ allowNumber: true, allowAny: false, allowBoolean: false, allowNullish: false, allowRegExp: false, allowNever: false }), 4);
});
test('con noUncheckedIndexedAccess, strictTypeChecked da 109: uno menos y 5 nuevos', () => {
const dir = freshKallpa('typed-nuia');
copyFileSync(resolve(HERE, 'eslint.estricto.js'), resolve(dir, 'eslint.config.js'));
const locations = () => {
const { output } = run('eslint', ['.', '-f', 'json'], dir);
const files = JSON.parse(output) as { filePath: string; messages: { line: number; column: number; ruleId: string }[] }[];
return files.flatMap((f) => f.messages.map((m) => `${f.filePath}:${m.line}:${m.column} ${m.ruleId}`));
};
const before = locations();
replaceInFile(resolve(dir, 'tsconfig.json'), '"strict": true,', '"strict": true, "noUncheckedIndexedAccess": true,');
const after = locations();
assert.equal(before.length, 105);
assert.equal(after.length, 109);
const added = after.filter((l) => !before.includes(l));
const removed = before.filter((l) => !after.includes(l));
assert.equal(added.length, 5);
assert.ok(added.every((l) => l.includes('restrict-template-expressions') && /(clock|handlers)\.ts/.test(l)));
assert.equal(removed.length, 1);
assert.match(removed[0] ?? '', /static-files\.ts.*no-unnecessary-condition/);
});
test('la regla de SQL da un solo aviso en Kallpa, en src/seed.ts', () => {
const dir = freshKallpa('sql');
const { output } = run('eslint', ['--no-config-lookup', '-c', resolve(HERE, 'eslint.sql.js'), 'src', '-f', 'json'], dir);
const files = JSON.parse(output) as { filePath: string; messages: { line: number }[] }[];
const hits = files.flatMap((f) => f.messages.map((m) => `${f.filePath.slice(dir.length + 1)}:${m.line}`));
assert.deepEqual(hits, ['src/seed.ts:18']);
});
test('en src/ hay 14 as y 2 !, según el árbol sintáctico', () => {
const dir = freshKallpa('fronteras');
const { output } = run('eslint', ['--no-config-lookup', '-c', resolve(HERE, 'eslint.fronteras.js'), 'src', '-f', 'json'], dir);
const files = JSON.parse(output) as { messages: { message: string }[] }[];
const messages = files.flatMap((f) => f.messages.map((m) => m.message));
assert.equal(messages.filter((m) => m === 'as').length, 14);
assert.equal(messages.filter((m) => m === 'no-null').length, 2);
});// El material del paso 2 del laboratorio: que se pueda aplicar a una copia de
// Kallpa, que solo toque lo que puede tocar y que se presente como ilustración.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { test } from 'node:test';
import { applyPatch, freshKallpa, HERE } from './tools.ts';
const text = readFileSync(resolve(HERE, 'pr-generado.diff'), 'utf8');
test('se aplica a la Kallpa publicada', () => {
assert.equal(applyPatch(freshKallpa('pr-publicada'), 'pr-generado.diff').code, 0);
});
test('no toca los archivos que cambian en el laboratorio de la semana 10', () => {
const files = [...text.matchAll(/^diff --git a\/(\S+)/gm)].map((m) => m[1]);
assert.ok(files.length > 0);
for (const file of ['src/domain/pricing.ts', 'src/http/router.ts', 'src/domain/booking.ts']) {
assert.ok(!files.includes(file), file);
}
});
test('se presenta como una ilustración escrita para la clase', () => {
assert.match(text.split('\n')[0] ?? '', /^Ilustración escrita para la clase/);
});Compilación
Cómo se compilan y ejecutan los ejemplos.
# Semana 11: revisión de código y análisis estático. Se usa dentro del
# contenedor del curso.
#
# make copia Kallpa en build/kallpa, para medirla sin tocar la
# original
# make test revisa los tipos (tsc), corre ESLint y las pruebas
# (*.test.ts), que comprueban lo que dice el apunte, y las del
# laboratorio si está la carpeta solution/
# make medir los números de Kallpa con las opciones estrictas de tsc y con
# strictTypeChecked, agrupados por regla
# make fronteras los as de Kallpa por archivo, según el árbol sintáctico
# make errores el error de tsc de ejemplos/invented-method.ts, que falla a
# propósito
# make busqueda aplica busqueda-agente.diff a una copia de Kallpa en
# build/busqueda y corre su make check
# make clean borra build/
#
# Los ejemplos leen la Kallpa del curso (../../kallpa) sin modificarla. El pull
# request del paso 2 del laboratorio está en pr-generado.diff.
KALLPA ?= ../../kallpa
GIT = git -c user.name=Kallpa -c user.email=kallpa@example.com -c init.defaultBranch=main
HERE = $(CURDIR)
.PHONY: all test medir fronteras errores busqueda clean
all: build/kallpa
build/kallpa:
mkdir -p build
cp -r $(KALLPA) $@
rm -f $@/data/*.db $@/data/*.db-*
test:
tsc -p .
eslint .
node --test "*.test.ts"
@if [ -d solution ]; then node --test "solution/*.test.ts"; fi
medir:
rm -rf build/medir
mkdir -p build
cp -r $(KALLPA) build/medir
rm -f build/medir/data/*.db build/medir/data/*.db-*
@echo "--- noUncheckedIndexedAccess"
@cd build/medir && sed 's/"strict": true,/"strict": true, "noUncheckedIndexedAccess": true,/' tsconfig.json > tsconfig.a.json && tsc -p tsconfig.a.json | grep -c 'error TS' || true
@echo "--- exactOptionalPropertyTypes"
@cd build/medir && sed 's/"strict": true,/"strict": true, "exactOptionalPropertyTypes": true,/' tsconfig.json > tsconfig.b.json && tsc -p tsconfig.b.json | grep -c 'error TS' || true
@echo "--- strictTypeChecked, por regla"
@cd build/medir && rm tsconfig.a.json tsconfig.b.json && cp $(HERE)/eslint.estricto.js eslint.config.js && (eslint . -f json || true) | jq -r '.[].messages[].ruleId' | sort | uniq -c | sort -rn
fronteras: all
@cp eslint.fronteras.js build/kallpa/fronteras.config.js
@cd build/kallpa && (eslint --no-config-lookup -c fronteras.config.js src -f json || true) | jq -r '.[] | .filePath as $$f | .messages[] | select(.message == "as") | $$f | sub(".*/src/"; "src/")' | sort | uniq -c
errores:
-tsc --ignoreConfig --noEmit --strict --target es2024 --module nodenext --types node --skipLibCheck ejemplos/invented-method.ts
busqueda:
rm -rf build/busqueda
mkdir -p build
cp -r $(KALLPA) build/busqueda
rm -f build/busqueda/data/*.db build/busqueda/data/*.db-*
cd build/busqueda && $(GIT) init -q && $(GIT) add -A && $(GIT) commit -qm "Kallpa publicada"
cd build/busqueda && $(GIT) apply $(HERE)/busqueda-agente.diff
$(MAKE) --no-print-directory -C build/busqueda check
clean:
rm -rf build{
"name": "week-11",
"private": true,
"type": "module"
}{
"compilerOptions": {
"target": "es2024",
"lib": ["es2024"],
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["*.ts", "ejemplos"],
"exclude": ["build", "solution", "ejemplos/invented-method.ts"]
}