@lbdudc/linea-core
v0.1.1
Published
Motor autocontenido de Líneas de Producto Software (SPL): feature models, validación de specs y derivación de productos. Funciona 100% offline.
Readme
linea-core
Motor autocontenido de Líneas de Producto Software (SPL): feature models, validación de specs y derivación de productos.
Es una librería de funciones JavaScript/TypeScript sin estado (ADR-014): cada función recibe todo lo que necesita por parámetros y devuelve resultados explícitos. Sin estado interno entre llamadas, sin singletons, sin inyección de dependencias, sin configuración global y sin base de datos propia.
Funciona 100 % offline (ADR-002): ni GitLab, ni HTTP, ni telemetría. Toda
integración remota vive en linea-server (ADR-003).
Instalación
El paquete se consume como dependencia git mientras no se publique en npm:
{
"dependencies": {
"@lbdudc/linea-core": "git+https://gitlab.lbd.udc.es/linea/linea-core.git"
}
}npm install ejecuta prepare → npm run build, que compila TypeScript a
dist/. No hace falta ningún paso manual.
Aviso: si instalas con
npm ci --ignore-scripts,prepareno se ejecuta y el paquete queda sindist/, por lo que fallará al importarlo. Es una limitación inherente a las dependencias git; desaparece cuando se publique en npm.
Requiere Node >= 20.11. Verificado también bajo Bun 1.3.x.
Uso
import {
readFeatureModel,
convertFeatureModel,
parseFeatureModel,
computeMandatoryFeatures,
estimateFeatureCount,
normalizeUvl,
resolveLinePaths
} from '@lbdudc/linea-core'
// Leer el feature model de un directorio de trabajo de línea (UVL o XML).
const { type, path, content } = readFeatureModel({ lineDir: '/tmp/mi-linea' })
// Convertir de notación.
const { content: xml } = convertFeatureModel({ content, from: 'UVL', to: 'XML' })
// Inspeccionar.
const model = parseFeatureModel({ content, type })
const { voidModel, mandatory } = computeMandatoryFeatures({ model })
// Helpers de texto y de rutas.
const features = estimateFeatureCount({ uvl: normalizeUvl({ uvl: content }) })
const paths = resolveLinePaths({ lineDir: '/tmp/mi-linea' })Selección de features
import {
normalizeFeatureSelection,
validateFeatureSelection,
assertFeatureSelection,
validateModelAgainstSelections
} from '@lbdudc/linea-core'
// Acepta las tres formas históricas de spec.features.
const features = normalizeFeatureSelection({ spec })
// Sin excepciones: devuelve un veredicto.
const { valid, completed, error, errorType } = validateFeatureSelection({ model, features })
// Con excepción: lanza FeatureSelectionInvalidError.
const expanded = assertFeatureSelection({ model, features })
// ¿Sigue el modelo admitiendo todos estos productos?
const { valid: ok, broken } = validateModelAgainstSelections({
model,
selections: [{ name: 'producto-a', features: ['A', 'B'] }]
})Spec de producto
import {
validateProductSpec,
normalizeProductSpec,
incrementVersion,
buildEngineSpec
} from '@lbdudc/linea-core'
// Sin excepciones. errors[].messageKey es SIEMPRE una clave i18n.
const { valid, errors } = validateProductSpec({ spec })
const completo = normalizeProductSpec({ spec }) // aplica los defaults
incrementVersion({ version: '1.2.3', index: 1 }) // '1.3.0'
const engineSpec = buildEngineSpec({ spec }) // forma que espera spl-js-engineÍndice de productos (ADR-011)
import {
readProductsIndex,
addProductToIndex,
writeProductsIndex,
reconcileProductsIndex
} from '@lbdudc/linea-core'
// Una línea sin products.json devuelve un índice vacío, no lanza.
const index = readProductsIndex({ lineDir: '/tmp/mi-linea' })
// Todas las mutaciones son INMUTABLES: devuelven un índice nuevo.
// `version` es OPCIONAL: si se omite, la entrada no lleva la clave (los
// products.json anteriores al campo siguen valiendo y no cambian de bytes).
const siguiente = addProductToIndex({
index,
product: {
name: 'mi-producto',
repositorio: '/lab/mi-linea/mi-producto',
version: '1.0.0'
}
})
writeProductsIndex({ lineDir: '/tmp/mi-linea', index: siguiente })
// `removed` es informativo: no se poda nada (no hay BD que reconciliar).
const { index: reconciliado, added, removed } = reconcileProductsIndex({
index,
repositories: [{ name: 'mi-producto', repositorio: '/lab/mi-linea/mi-producto' }]
})La serialización es determinista (orden alfabético por name, solo los dos
campos normativos, salto de línea final) para que los commits de linea-server no
generen diffs de ruido.
La API pública es una sola entrada (.). Todo lo que hay bajo src/ aparte
de src/index.ts es privado y puede cambiar sin aviso.
Convención de llamada
Todas las funciones públicas reciben un único objeto de parámetros con
nombres, incluidas las de un solo argumento: normalizeUvl({ uvl }),
estimateFeatureCount({ uvl }), resolveLinePaths({ lineDir }),
readTextFile({ filePath }), resolveAsset({ segments }). Es más verboso que
un argumento posicional, pero mantiene un contrato uniforme para linea-server y
linea-cli y permite añadir parámetros sin romper a nadie.
Crear una línea
import { buildLineScaffold, writeLineScaffold } from '@lbdudc/linea-core'
// PURO: no toca disco ni red. Devuelve los ficheros en el mismo orden que
// LineService.createLine, listos para gitlabSession.createFile.
const files = buildLineScaffold({ name: 'MiLinea', models: ['UML'] })
// -> [{ path: 'src/platform/model.uvl', content: '...', encoding: 'text' }, ...]
// IMPURO: los materializa en disco (modo local de linea-cli).
const { dir } = writeLineScaffold({ dir: '/tmp/mi-linea', name: 'MiLinea' })El par puro/impuro es deliberado: linea-server sube los ficheros a GitLab sin que el core toque nada, y linea-cli escribe el árbol en local desde la misma descripción.
Derivar un producto
import { deriveProduct, deriveProductToZip } from '@lbdudc/linea-core'
const spec = { basicData: { name: 'MiTienda', version: '1.0.0' }, features: ['Shop', 'Payment'] }
await deriveProduct({ lineDir: '/tmp/mi-linea', spec, outputDir: '/tmp/producto' })
const zip = await deriveProductToZip({ lineDir: '/tmp/mi-linea', spec }) // BufferErrores: selección inválida → FeatureSelectionInvalidError; plataforma
inservible o cualquier otro fallo del motor → LinePlatformNotFoundError (si una
anotación llama a una función que extra.js no define, el mensaje es la clave
error_messages.missing_function_error).
La derivación no borra nada: los directorios son del llamador.
Aviso al escribir un
extra.js: no uses comentarios//. El procesador de plantillas elimina todos los saltos de línea del código que construye (processor.js:28), así que un comentario de línea se traga el resto de la función generada y el fichero sale vacío. Usa/* ... */.
Archivo, diff y formato
import { extractArchive, diffDirectories, formatDirectory } from '@lbdudc/linea-core'
const { dir } = await extractArchive({ source: '/tmp/a.zip', destDir: '/tmp/out', prefix: 'milinea' })
await formatDirectory({ dir })
const changes = diffDirectories({ oldDir: '/tmp/anterior', newDir: dir })
// -> [{ action: 'update', file_path: 'src/App.java', content: '...', encoding: 'text' }, ...]FileChange usa el vocabulario de acciones de commit de GitLab (action,
file_path, content, encoding) a propósito, para que linea-server se lo
pase tal cual a gitlabSession.commit sin adaptador. Es un formato neutro de
cambio de fichero: una forma de datos, no una dependencia. file_path es
siempre POSIX y relativo.
Analizar un feature model
import {
createAnalysisRuntime, disposeAnalysisRuntime,
analyzeFeatureModel, analyzeFeatureModelBdd, analyzeMetric
} from '@lbdudc/linea-core'
const runtime = await createAnalysisRuntime() // ~2,3 s: arranca Pyodide
try {
const info = await analyzeFeatureModel({ uvl, runtime })
// -> { coreFeatures, satisfiable, deadFeatures, maxDepth, ..., _skipped: [] }
const bdd = await analyzeFeatureModelBdd({ uvl, runtime })
const one = await analyzeMetric({ uvl, metric: 'deadFeatures', runtime })
} finally {
disposeAnalysisRuntime({ runtime })
}El runtime es del llamador (ADR-014): no hay singleton de módulo. Puede
omitirse, y entonces cada llamada crea el suyo — correcto pero lento, así que
quien analice más de una vez debería mantener uno.
Dos cosas que hay que saber antes de usarlo en un servidor:
disposeAnalysisRuntimeNO libera memoria. Pyodide no ofrece teardown, así que los ~180 MB de heap WASM se quedan hasta que muera el proceso;disposesolo inutiliza el handle. Crear un runtime por petición filtra un intérprete cada vez: hay que mantener unos pocos y reutilizarlos.- Si faltan los wheels vendorizados, el arranque falla en el acto. No hay
degradación silenciosa: es el canario de ADR-021.
packageCacheDirde Pyodide es una caché y no un override, así que sin esa comprobación un wheel ausente se descargaría decdn.jsdelivr.nety el módulo estaría usando la red sin que nadie se enterase.
El contrato de salida es idéntico al de linea/flamapy-server/main.py,
incluidos los umbrales (SKIP_ENUM, SKIP_BDD) y el patrón safe(): un fallo
individual devuelve null en su campo y lo anota en _skipped, sin abortar el
resto de la respuesta.
Errores de validación: messageKey es una clave i18n
validateProductSpec y validateProductsIndex devuelven
{ path, code, messageKey }. messageKey es siempre una clave, nunca una
frase legible: pásala por la función de traducción del consumidor y ramifica con
code, que es estable.
Cinco claves vienen del monolito y ya están traducidas; diez son nuevas y
todavía no, así que hoy se pintarían literales. Ver la tabla del
CHANGELOG.md, que es la lista de trabajo para quien migre webui y cli.
Notación explícita, nunca inferida
Las funciones que reciben un modelo exigen el type exacto ('UVL' o 'XML').
El monolito lo relaja con (type ?? 'UVL').toUpperCase() porque
line.featureModelType puede venir undefined de Mongo en líneas antiguas
(LineService.js:284,401,432). Aplicar ese valor por defecto es
responsabilidad del llamador: pasa line.featureModelType ?? 'UVL'.
Estructura de un directorio de línea
Se corresponde con paths.platform.* de
linea/server/src/resources/properties.yml:
<lineDir>/
products.json índice de productos (ADR-011)
src/platform/
model.uvl | model.xml feature model (UVL preferido)
config.json configuración del motor de derivación
extra.js helpers de plantilla
transformation.js transformación de la spec
code/ código anotado que consume spl-js-engineDesarrollo
npm install
npm run typecheck # tsc --noEmit sobre src, test y scripts
npm run lint # eslint 9 flat config + typescript-eslint
npm run test # vitest run
npm run build # tsc + copia de assets a dist/Los ficheros que no son TypeScript bajo src/ (por ejemplo
src/line/templates/**) son assets: scripts/copy-assets.mjs los replica en
dist/ conservando su ruta relativa, y resolveAsset() los localiza vía
import.meta.url. Así la ruta relativa al módulo es la misma en fuente y en
dist/.
Decisiones de arquitectura relevantes
| ADR | Qué fija |
|---|---|
| ADR-001 | Separación en 4 módulos; core no depende de nadie |
| ADR-002 | linea-core funciona sin conexión a internet |
| ADR-003 | Solo linea-server habla con GitLab |
| ADR-008 | Análisis de feature models con @lbdudc/flamapy.js |
| ADR-009 | Persistencia en GitLab; el core trabaja sobre directorios |
| ADR-011 | Índice de productos en products.json |
| ADR-014 | linea-core es una librería de funciones sin estado |
Paridad funcional: el monolito linea/ es la referencia de comportamiento. Cada
función documenta en su JSDoc el fichero y la función original que migra.
Deuda técnica conocida
spl-js-engineapunta a un commit de una rama sin publicar. La versión4.0.5del registro npm no tiene soporte UVL en absoluto (fromUVLytoUVLsonundefined, y faltafeature-model-uvl.js), así que es obligatorio usar la rama#next. Está fijada al commitcd67af2fd2f4019e99cbeb5ac4a8622f5549e68epor reproducibilidad y víagit+https://(nuncagit+ssh://, que obligaría a cada consumidor a tener claves de GitHub). Migrar a una versión publicada en cuanto exista.- Ruido en consola del parser UVL.
uvl-parser/src/FeatureModel.js:22-28decide si su argumento es una ruta confs.statSyncy haceconsole.error('Error: ' + e)cuando no lo es, volcando el modelo entero a stderr en cada parseo.src/feature-model/engine.tsfiltra exactamente ese mensaje. Retirar el filtro si el upstream lo corrige. - Licencia sin confirmar.
package.jsondeclaraUNLICENSEDcomo marcador. TODO: actualizar cuando dirección responda. - Wheels de Python vendorizados (ADR-021).
@lbdudc/flamapy.jsdescargamicropipypackagingdecdn.jsdelivr.neten una instalación limpia, lo que rompe ADR-002, y sin red no falla: devuelveundefineden silencio. Por esosrc/analysis/no usa susetupPyodidesino un arranque propio, y lleva los dos wheels (319 KB) dentro del paquete. Es una mitigación con fecha de caducidad: si el laboratorio hace que@lbdudc/flamapy.jsincluyamicropipy acepte configurarindexURL/packageCacheDir, hay que retirar el vendorizado. disposeAnalysisRuntimeno libera memoria (ADR-029). Pyodide no expone teardown, así que los ~180 MB de heap WASM de cada intérprete siguen ocupados hasta que muere el proceso;disposesolo inutiliza el handle. linea-server no puede crear un runtime por petición: debe mantener unos pocos y reutilizarlos.- Los tests de análisis corren cerca del techo de memoria. Por eso están
repartidos en cuatro ficheros (Vitest da un worker por fichero, y es la única
forma de mantener los heaps WASM separados):
analyze.test.tscomparte dos runtimes para sus 22 tests, yruntime.test.tsywheels-missing.test.tsaíslan los que arrancan su propia instancia. No añadas más instancias a un fichero existente; crea uno nuevo. Se observó una vez un fallo intermitente de 2 tests bajo carga que no se reprodujo en seis pasadas posteriores, así que el margen es estrecho: si el runner de CI tiene menos memoria que una máquina de desarrollo, este es el primer sitio donde lo vas a notar.
