diff --git a/mon-entreprise/test/cycles.test.js b/mon-entreprise/test/cycles.test.js new file mode 100644 index 000000000..cbf0601f2 --- /dev/null +++ b/mon-entreprise/test/cycles.test.js @@ -0,0 +1,27 @@ +import { expect } from 'chai' +import { cyclesLib } from 'publicodes' +import rules from '../source/rules' + +describe('DottedNames graph', () => { + it("shouldn't have cycles", () => { + let cyclesDependencies = cyclesLib.cyclicDependencies(rules) + + expect( + cyclesDependencies.reverse(), + `\nThe cycles have been found in the rules dependencies graph.\nSee below for a representation of each cycle.\n⬇️ is a node of the cycle.\n↘️ is each of the dependencies of this node.\n\t- ${cyclesDependencies + .map( + (cycleDependencies, idx) => + '#' + + idx + + ':\n\t\t⬇️ ' + + cycleDependencies + .map( + ([ruleName, dependencies]) => + ruleName + '\n\t\t\t↘️ ' + dependencies.join('\n\t\t\t↘️ ') + ) + .join('\n\t\t⬇️ ') + ) + .join('\n\t- ')}\n\n` + ).to.be.an('array').that.is.empty + }) +}) diff --git a/publicodes/package.json b/publicodes/package.json index e33bfffcc..8af6cef0a 100644 --- a/publicodes/package.json +++ b/publicodes/package.json @@ -18,6 +18,9 @@ "dist/images" ], "private": false, + "devDependencies": { + "@dagrejs/graphlib": "^2.1.4" + }, "dependencies": { "classnames": "^2.2.6", "focus-trap-react": "^3.1.2", diff --git a/publicodes/source/cyclesLib/ASTTypes.ts b/publicodes/source/cyclesLib/ASTTypes.ts new file mode 100644 index 000000000..d2bd0b1a1 --- /dev/null +++ b/publicodes/source/cyclesLib/ASTTypes.ts @@ -0,0 +1,535 @@ +/** + * Note: all here is strictly based on duck typing. + * We don't exepect the parent rule to explain the type of the contained formula, for example. + */ +import * as R from 'ramda' +import { ParsedRule } from '../types' +import { ArrondiExplanation } from '../mecanisms/arrondi' + +export type OnOff = 'oui' | 'non' +export function isOnOff(a: string): a is OnOff { + return a === 'oui' || a === 'non' +} + +// Note: to build type-guards, we would need to have a `isNames` guard. That's +// pretty cumbersome, so for now we rely on this. +export type WannabeDottedName = string +export function isWannabeDottedName(a: string): a is WannabeDottedName { + return typeof a === 'string' +} + +export type ASTNode = { [_: string]: any | undefined } + +export type RuleNode = ASTNode & ParsedRule + +export type RuleProp = ASTNode & { + category: 'ruleProp' + rulePropType: string +} +export function isRuleProp(node: ASTNode): node is RuleProp { + return ( + (node as RuleProp).category === 'ruleProp' && + typeof (node as RuleProp).rulePropType === 'string' + ) +} + +export type Formule = RuleProp & { + name: 'formule' + rulePropType: 'formula' + explanation: FormuleExplanation +} +export function isFormule( + node: ASTNode +): node is Formule { + const formule = node as Formule + return ( + isRuleProp(node) && + formule.name === 'formule' && + formule.rulePropType === 'formula' && + isFormuleExplanation(formule.explanation) + ) +} + +export type FormuleExplanation = + | Value + | Operation + | Possibilities + | Possibilities2 + | Reference + | AnyMechanism +export function isFormuleExplanation( + node: ASTNode +): node is FormuleExplanation { + return ( + isValue(node) || + isOperation(node) || + isReference(node) || + isPossibilities(node) || + isPossibilities2(node) || + isAnyMechanism(node) + ) +} + +export type Value = ASTNode & { + nodeValue: number | string | boolean +} +export function isValue(node: ASTNode): node is Value { + const value = node as Value + return ( + typeof value.nodeValue === 'string' || + typeof value.nodeValue === 'number' || + typeof value.nodeValue === 'boolean' + ) +} + +export type Operation = ASTNode & { + operationType: 'comparison' | 'calculation' + explanation: Array +} +export function isOperation(node: ASTNode): node is Operation { + return R.includes((node as Operation).operationType, [ + 'comparison', + 'calculation' + ]) +} + +export type Possibilities = ASTNode & { + possibilités: Array + 'choix obligatoire'?: OnOff + 'une possibilité': OnOff +} +export function isPossibilities(node: ASTNode): node is Possibilities { + const possibilities = node as Possibilities + return ( + possibilities.possibilités instanceof Array && + possibilities.possibilités.every(it => typeof it === 'string') && + (possibilities['choix obligatoire'] === undefined || + isOnOff(possibilities['choix obligatoire'])) && + isOnOff(possibilities['une possibilité']) + ) +} +export type Possibilities2 = ASTNode & { + [index: number]: string // short dotted name + 'choix obligatoire'?: OnOff + 'une possibilité': OnOff +} +export function isPossibilities2(node: ASTNode): node is Possibilities2 { + const possibilities2 = node as Possibilities2 + return ( + Object.entries(possibilities2).every( + ([k, v]) => isNaN(parseInt(k, 10)) || typeof v === 'string' + ) && + (possibilities2['choix obligatoire'] === undefined || + isOnOff(possibilities2['choix obligatoire'])) && + isOnOff(possibilities2['une possibilité']) + ) +} + +export type Reference = ASTNode & { + category: 'reference' + name: Names + partialReference: Names + dottedName: Names +} +export function isReference( + node: ASTNode +): node is Reference { + const reference = node as Reference + return ( + reference.category === 'reference' && + isWannabeDottedName(reference.name) && + isWannabeDottedName(reference.partialReference) && + isWannabeDottedName(reference.dottedName) + ) +} + +export type AbstractMechanism = ASTNode & { + category: 'mecanism' + name: string +} +export function isAbstractMechanism(node: ASTNode): node is AbstractMechanism { + return ( + (node as AbstractMechanism).category === 'mecanism' && + typeof (node as AbstractMechanism).name === 'string' + ) +} + +export type RecalculMech = AbstractMechanism & { + explanation: { + recalcul: Reference + amendedSituation: Record> + } +} +export function isRecalculMech( + node: ASTNode +): node is RecalculMech { + const recalculMech = node as RecalculMech + const isReferenceSpec = isReference as ( + node: ASTNode + ) => node is Reference + return ( + typeof recalculMech.explanation === 'object' && + typeof recalculMech.explanation.recalcul === 'object' && + isReferenceSpec(recalculMech.explanation.recalcul as ASTNode) && + typeof recalculMech.explanation.amendedSituation === 'object' + ) +} + +export type EncadrementMech = AbstractMechanism & { + name: 'encadrement' + explanation: { + valeur: ASTNode + plafond: ASTNode + plancher: ASTNode + } +} +export function isEncadrementMech(node: ASTNode): node is EncadrementMech { + const encadrementMech = node as EncadrementMech + return ( + isAbstractMechanism(encadrementMech) && + encadrementMech.name == 'encadrement' && + typeof encadrementMech.explanation === 'object' && + encadrementMech.explanation.valeur !== undefined && + encadrementMech.explanation.plafond !== undefined && + encadrementMech.explanation.plancher !== undefined + ) +} + +export type SommeMech = AbstractMechanism & { + name: 'somme' + explanation: Array +} +export function isSommeMech(node: ASTNode): node is SommeMech { + const sommeMech = node as SommeMech + return ( + isAbstractMechanism(sommeMech) && + sommeMech.name === 'somme' && + sommeMech.explanation instanceof Array + ) +} + +export type ProduitMech = AbstractMechanism & { + name: 'produit' + explanation: { + assiette: ASTNode + plafond: ASTNode + facteur: ASTNode + taux: ASTNode + } +} +export function isProduitMech(node: ASTNode): node is ProduitMech { + const produitMech = node as ProduitMech + return ( + isAbstractMechanism(produitMech) && + produitMech.name === 'produit' && + typeof produitMech.explanation === 'object' && + typeof produitMech.explanation.assiette === 'object' && + typeof produitMech.explanation.plafond === 'object' && + typeof produitMech.explanation.facteur === 'object' && + typeof produitMech.explanation.taux === 'object' + ) +} + +export type VariationsMech = AbstractMechanism & { + name: 'variations' + explanation: { + condition: ASTNode + consequence: ASTNode + }[] +} +export function isVariationsMech(node: ASTNode): node is VariationsMech { + const variationsMech = node as VariationsMech + return ( + isAbstractMechanism(variationsMech) && + variationsMech.name === 'variations' && + variationsMech.explanation instanceof Array && + variationsMech.explanation.every( + variation => + typeof variation === 'object' && + variation.condition !== undefined && + variation.consequence !== undefined + ) + ) +} + +export type AllegementMech = AbstractMechanism & { + name: 'allègement' + explanation: { + abattement: ASTNode + assiette: ASTNode + plafond: ASTNode + } +} +export function isAllegementMech(node: ASTNode): node is AllegementMech { + const allegementMech = node as AllegementMech + return ( + isAbstractMechanism(allegementMech) && + allegementMech.name === 'allègement' && + typeof allegementMech.explanation === 'object' && + allegementMech.explanation.abattement !== undefined && + allegementMech.explanation.assiette !== undefined && + allegementMech.explanation.plafond !== undefined + ) +} + +export type BaremeMech = AbstractMechanism & { + name: 'barème' + explanation: { + assiette: ASTNode + multiplicateur: ASTNode + tranches: { + plafond: ASTNode + taux: ASTNode + }[] + } +} +export function isBaremeMech(node: ASTNode): node is BaremeMech { + const baremeMech = node as BaremeMech + return ( + isAbstractMechanism(baremeMech) && + baremeMech.name === 'barème' && + typeof baremeMech.explanation === 'object' && + baremeMech.explanation.assiette !== undefined && + baremeMech.explanation.multiplicateur !== undefined && + baremeMech.explanation.tranches instanceof Array && + baremeMech.explanation.tranches.every( + tranche => + typeof tranche === 'object' && + tranche.plafond !== undefined && + tranche.taux !== undefined + ) + ) +} + +export type InversionNumMech = AbstractMechanism & { + name: 'inversion numérique' + explanation: { + inversionCandidates: Array> + } +} +export function isInversionNumMech( + node: ASTNode +): node is InversionNumMech { + const inversionNumMech = node as InversionNumMech + const isReferenceSpec = isReference as ( + node: ASTNode + ) => node is Reference + return ( + isAbstractMechanism(inversionNumMech) && + inversionNumMech.name === 'inversion numérique' && + typeof inversionNumMech.explanation === 'object' && + inversionNumMech.explanation.inversionCandidates instanceof Array && + inversionNumMech.explanation.inversionCandidates.every(isReferenceSpec) + ) +} + +export type ArrondiMech = AbstractMechanism & { + name: 'arrondi' + explanation: Record +} +export function isArrondiMech(node: ASTNode): node is ArrondiMech { + const arrondiMech = node as ArrondiMech + return ( + isAbstractMechanism(arrondiMech) && + arrondiMech.name === 'arrondi' && + typeof arrondiMech.explanation === 'object' && + arrondiMech.explanation.decimals !== undefined && + arrondiMech.explanation.value !== undefined + ) +} + +export type MaxMech = AbstractMechanism & { + name: 'le maximum de' + explanation: Array +} +export function isMaxMech(node: ASTNode): node is MaxMech { + const maxMech = node as MaxMech + return ( + isAbstractMechanism(maxMech) && + maxMech.name === 'le maximum de' && + maxMech.explanation instanceof Array + ) +} + +export type MinMech = AbstractMechanism & { + name: 'le minimum de' + explanation: Array +} +export function isMinMech(node: ASTNode): node is MinMech { + const minMech = node as MinMech + return ( + isAbstractMechanism(minMech) && + minMech.name === 'le minimum de' && + minMech.explanation instanceof Array + ) +} + +export type ComposantesMech = AbstractMechanism & { + name: 'composantes' + explanation: Array +} +export function isComposantesMech(node: ASTNode): node is ComposantesMech { + const composantesMech = node as ComposantesMech + return ( + isAbstractMechanism(composantesMech) && + composantesMech.name === 'composantes' && + composantesMech.explanation instanceof Array + ) +} + +export type UneConditionsMech = AbstractMechanism & { + name: 'une de ces conditions' + explanation: Array +} +export function isUneConditionsMech(node: ASTNode): node is UneConditionsMech { + const uneConditionsMech = node as UneConditionsMech + return ( + isAbstractMechanism(uneConditionsMech) && + uneConditionsMech.name === 'une de ces conditions' && + uneConditionsMech.explanation instanceof Array + ) +} + +export type ToutesConditionsMech = AbstractMechanism & { + name: 'toutes ces conditions' + explanation: Array +} +export function isToutesConditionsMech( + node: ASTNode +): node is ToutesConditionsMech { + const toutesConditionsMech = node as ToutesConditionsMech + return ( + isAbstractMechanism(toutesConditionsMech) && + toutesConditionsMech.name === 'toutes ces conditions' && + toutesConditionsMech.explanation instanceof Array + ) +} + +export type SyncMech = AbstractMechanism & { + name: 'synchronisation' + API: any +} +export function isSyncMech(node: ASTNode): node is SyncMech { + const syncMech = node as SyncMech + return isAbstractMechanism(syncMech) && syncMech.name === 'synchronisation' +} + +export type GrilleMech = AbstractMechanism & { + name: 'grille' + explanation: { + assiette: ASTNode + multiplicateur: ASTNode + tranches: { + montant: ASTNode + plafond: ASTNode + }[] + } +} +export function isGrilleMech(node: ASTNode): node is GrilleMech { + const grilleMech = node as GrilleMech + return ( + isAbstractMechanism(grilleMech) && + grilleMech.name === 'grille' && + typeof grilleMech.explanation === 'object' && + grilleMech.explanation.assiette !== undefined && + grilleMech.explanation.multiplicateur !== undefined && + grilleMech.explanation.tranches instanceof Array && + grilleMech.explanation.tranches.every( + tranche => + typeof tranche === 'object' && + tranche.montant !== undefined && + tranche.plafond !== undefined + ) + ) +} + +export type TauxProgMech = AbstractMechanism & { + name: 'taux progressif' + explanation: { + assiette: ASTNode + multiplicateur: ASTNode + tranches: { + plafond: ASTNode + taux: ASTNode + }[] + } +} +export function isTauxProgMech(node: ASTNode): node is TauxProgMech { + const tauxProgMech = node as TauxProgMech + return ( + isAbstractMechanism(tauxProgMech) && + tauxProgMech.name === 'taux progressif' && + typeof tauxProgMech.explanation === 'object' && + tauxProgMech.explanation.assiette !== undefined && + tauxProgMech.explanation.multiplicateur !== undefined && + tauxProgMech.explanation.tranches instanceof Array && + tauxProgMech.explanation.tranches.every( + tranche => + typeof tranche === 'object' && + tranche.plafond !== undefined && + tranche.taux !== undefined + ) + ) +} + +export type DureeMech = AbstractMechanism & { + name: 'Durée' + explanation: { + depuis: ASTNode + "jusqu'à": ASTNode + } +} +export function isDureeMech(node: ASTNode): node is DureeMech { + const dureeMech = node as DureeMech + return ( + isAbstractMechanism(dureeMech) && + dureeMech.name === 'Durée' && + typeof dureeMech.explanation === 'object' && + dureeMech.explanation.depuis !== undefined && + dureeMech.explanation["jusqu'à"] !== undefined + ) +} + +export type AnyMechanism = + | RecalculMech + | EncadrementMech + | SommeMech + | ProduitMech + | VariationsMech + | AllegementMech + | BaremeMech + | InversionNumMech + | ArrondiMech + | MaxMech + | MinMech + | ComposantesMech + | UneConditionsMech + | ToutesConditionsMech + | SyncMech + | GrilleMech + | TauxProgMech + | DureeMech +export function isAnyMechanism( + node: ASTNode +): node is AnyMechanism { + return ( + isRecalculMech(node) || + isEncadrementMech(node) || + isSommeMech(node) || + isProduitMech(node) || + isVariationsMech(node) || + isAllegementMech(node) || + isBaremeMech(node) || + isInversionNumMech(node) || + isArrondiMech(node) || + isMaxMech(node) || + isMinMech(node) || + isComposantesMech(node) || + isUneConditionsMech(node) || + isToutesConditionsMech(node) || + isSyncMech(node) || + isGrilleMech(node) || + isTauxProgMech(node) || + isDureeMech(node) + ) +} diff --git a/publicodes/source/cyclesLib/graph.ts b/publicodes/source/cyclesLib/graph.ts new file mode 100644 index 000000000..8c3d09a57 --- /dev/null +++ b/publicodes/source/cyclesLib/graph.ts @@ -0,0 +1,62 @@ +import * as R from 'ramda' +import graphlib from '@dagrejs/graphlib' +import parseRules from '../parseRules' +import { Rules } from '../types' +import { + buildRulesDependencies, + RuleDependencies, + RulesDependencies +} from './rulesDependencies' + +type GraphNodeRepr = Names +type GraphCycles = Array>> +type GraphCyclesWithDependencies = Array< + Array<[GraphNodeRepr, RuleDependencies]> +> + +function buildDependenciesGraph( + rulesDeps: RulesDependencies +): graphlib.Graph { + const g = new graphlib.Graph() + + rulesDeps.forEach(([ruleDottedName, dependencies]) => { + dependencies.forEach(depDottedName => { + g.setEdge(ruleDottedName, depDottedName) + }) + }) + + return g +} + +export function cyclesInDependenciesGraph( + rawRules: Rules | string +): GraphCycles { + const parsedRules = parseRules(rawRules) + const rulesDependencies = buildRulesDependencies(parsedRules) + const dependenciesGraph = buildDependenciesGraph(rulesDependencies) + const cycles = graphlib.alg.findCycles(dependenciesGraph) + + return cycles +} + +/** + * This function is useful so as to print the dependencies at each node of the + * cycle. + * ⚠️ Indeed, the graphlib.findCycles function returns the cycle found using the + * Tarjan method, which is **not necessarily the smallest cycle**. However, the + * smallest cycle would be the most legibe one… + */ +export function cyclicDependencies( + rawRules: Rules | string +): GraphCyclesWithDependencies { + const parsedRules = parseRules(rawRules) + const rulesDependencies = buildRulesDependencies(parsedRules) + const dependenciesGraph = buildDependenciesGraph(rulesDependencies) + const cycles = graphlib.alg.findCycles(dependenciesGraph) + + const rulesDependenciesObject = R.fromPairs(rulesDependencies) + + return cycles.map(cycle => + cycle.map(ruleName => [ruleName, rulesDependenciesObject[ruleName]]) + ) +} diff --git a/publicodes/source/cyclesLib/index.ts b/publicodes/source/cyclesLib/index.ts new file mode 100644 index 000000000..895d592cd --- /dev/null +++ b/publicodes/source/cyclesLib/index.ts @@ -0,0 +1,3 @@ +import { cyclicDependencies } from './graph' + +export default { cyclicDependencies } diff --git a/publicodes/source/cyclesLib/rulesDependencies.ts b/publicodes/source/cyclesLib/rulesDependencies.ts new file mode 100644 index 000000000..b73411fc6 --- /dev/null +++ b/publicodes/source/cyclesLib/rulesDependencies.ts @@ -0,0 +1,386 @@ +import * as R from 'ramda' +import { ParsedRules } from '../types' +import * as ASTTypes from './ASTTypes' + +export type RuleDependencies = Array +export type RulesDependencies = Array< + [Names, RuleDependencies] +> + +export function ruleDepsOfNode( + ruleName: Names, + node: ASTTypes.ASTNode +): RuleDependencies { + function ruleDepsOfFormule( + formule: ASTTypes.Formule + ): RuleDependencies { + return ruleDepsOfNode(ruleName, formule.explanation) + } + + function ruleDepsOfValue(value: ASTTypes.Value): RuleDependencies { + return [] + } + + function ruleDepsOfOperation( + operation: ASTTypes.Operation + ): RuleDependencies { + return operation.explanation.flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + } + + function ruleDepsOfPossibilities( + possibilities: ASTTypes.Possibilities + ): RuleDependencies { + return [] + } + function ruleDepsOfPossibilities2( + possibilities: ASTTypes.Possibilities2 + ): RuleDependencies { + return [] + } + + function ruleDepsOfReference( + reference: ASTTypes.Reference + ): RuleDependencies { + return [reference.dottedName] + } + + function ruleDepsOfRecalculMech( + recalculMech: ASTTypes.RecalculMech + ): RuleDependencies { + const ruleReference = recalculMech.explanation.recalcul.partialReference + return ruleReference === ruleName ? [] : [ruleReference] + } + + function ruleDepsOfEncadrementMech( + encadrementMech: ASTTypes.EncadrementMech + ): RuleDependencies { + const result = [ + encadrementMech.explanation.plafond, + encadrementMech.explanation.plancher, + encadrementMech.explanation.valeur + ].flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfSommeMech( + sommeMech: ASTTypes.SommeMech + ): RuleDependencies { + const result = sommeMech.explanation.flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfProduitMech( + produitMech: ASTTypes.ProduitMech + ): RuleDependencies { + const result = [ + produitMech.explanation.assiette, + produitMech.explanation.plafond, + produitMech.explanation.facteur, + produitMech.explanation.taux + ].flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfVariationsMech( + variationsMech: ASTTypes.VariationsMech + ): RuleDependencies { + function ruleOfVariation({ + condition, + consequence + }: { + condition: ASTTypes.ASTNode + consequence: ASTTypes.ASTNode + }): RuleDependencies { + return R.concat( + ruleDepsOfNode(ruleName, condition), + ruleDepsOfNode(ruleName, consequence) + ) + } + const result = variationsMech.explanation.flatMap(ruleOfVariation) + return result + } + + function ruleDepsOfAllegementMech( + allegementMech: ASTTypes.AllegementMech + ): RuleDependencies { + const subNodes = [ + allegementMech.explanation.abattement, + allegementMech.explanation.assiette, + allegementMech.explanation.plafond + ] + const result = subNodes.flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfBaremeMech( + baremeMech: ASTTypes.BaremeMech + ): RuleDependencies { + const tranchesNodes = baremeMech.explanation.tranches.flatMap( + ({ plafond, taux }) => [plafond, taux] + ) + const result = R.concat( + [baremeMech.explanation.assiette, baremeMech.explanation.multiplicateur], + tranchesNodes + ).flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + /** + * Returns 0 dependency for _inversion numérique_ as it's not creating a logical dependency. + */ + function ruleDepsOfInversionNumMech( + inversionNumMech: ASTTypes.InversionNumMech + ): RuleDependencies { + return [] + } + + function ruleDepsOfArrondiMech( + arrondiMech: ASTTypes.ArrondiMech + ): RuleDependencies { + const result = [ + arrondiMech.explanation.decimals, + arrondiMech.explanation.value + ].flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfMaxMech( + maxMech: ASTTypes.MaxMech + ): RuleDependencies { + const result = maxMech.explanation.flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfMinMech( + minMech: ASTTypes.MinMech + ): RuleDependencies { + const result = minMech.explanation.flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfComposantesMech( + composantesMech: ASTTypes.ComposantesMech + ): RuleDependencies { + const result = composantesMech.explanation.flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfUneConditionsMech( + uneConditionsMech: ASTTypes.UneConditionsMech + ): RuleDependencies { + const result = uneConditionsMech.explanation.flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfToutesConditionsMech( + toutesConditionsMech: ASTTypes.ToutesConditionsMech + ): RuleDependencies { + const result = toutesConditionsMech.explanation.flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfSyncMech(_: ASTTypes.SyncMech): RuleDependencies { + return [] + } + + function ruleDepsOfGrilleMech( + grilleMech: ASTTypes.GrilleMech + ): RuleDependencies { + const tranchesNodes = grilleMech.explanation.tranches.flatMap( + ({ montant, plafond }) => [montant, plafond] + ) + const result = R.concat( + [grilleMech.explanation.assiette, grilleMech.explanation.multiplicateur], + tranchesNodes + ).flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfTauxProgMech( + tauxProgMech: ASTTypes.TauxProgMech + ): RuleDependencies { + const tranchesNodes = tauxProgMech.explanation.tranches.flatMap( + ({ plafond, taux }) => [plafond, taux] + ) + const result = R.concat( + [ + tauxProgMech.explanation.assiette, + tauxProgMech.explanation.multiplicateur + ], + tranchesNodes + ).flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + function ruleDepsOfDureeMech( + dureeMech: ASTTypes.DureeMech + ): RuleDependencies { + const result = [ + dureeMech.explanation.depuis, + dureeMech.explanation["jusqu'à"] + ].flatMap( + R.partial>( + ruleDepsOfNode, + [ruleName] + ) + ) + return result + } + + let result + if (ASTTypes.isFormule(node)) { + result = ruleDepsOfFormule(node) + } else if (ASTTypes.isValue(node)) { + result = ruleDepsOfValue(node) + } else if (ASTTypes.isOperation(node)) { + result = ruleDepsOfOperation(node) + } else if (ASTTypes.isReference(node)) { + result = ruleDepsOfReference(node) + } else if (ASTTypes.isPossibilities(node)) { + result = ruleDepsOfPossibilities(node) + } else if (ASTTypes.isPossibilities2(node)) { + result = ruleDepsOfPossibilities2(node) + } else if (ASTTypes.isRecalculMech(node)) { + result = ruleDepsOfRecalculMech(node) + } else if (ASTTypes.isEncadrementMech(node)) { + result = ruleDepsOfEncadrementMech(node) + } else if (ASTTypes.isSommeMech(node)) { + result = ruleDepsOfSommeMech(node) + } else if (ASTTypes.isProduitMech(node)) { + result = ruleDepsOfProduitMech(node) + } else if (ASTTypes.isVariationsMech(node)) { + result = ruleDepsOfVariationsMech(node) + } else if (ASTTypes.isAllegementMech(node)) { + result = ruleDepsOfAllegementMech(node) + } else if (ASTTypes.isBaremeMech(node)) { + result = ruleDepsOfBaremeMech(node) + } else if (ASTTypes.isInversionNumMech(node)) { + result = ruleDepsOfInversionNumMech(node) + } else if (ASTTypes.isArrondiMech(node)) { + result = ruleDepsOfArrondiMech(node) + } else if (ASTTypes.isMaxMech(node)) { + result = ruleDepsOfMaxMech(node) + } else if (ASTTypes.isMinMech(node)) { + result = ruleDepsOfMinMech(node) + } else if (ASTTypes.isComposantesMech(node)) { + result = ruleDepsOfComposantesMech(node) + } else if (ASTTypes.isUneConditionsMech(node)) { + result = ruleDepsOfUneConditionsMech(node) + } else if (ASTTypes.isToutesConditionsMech(node)) { + result = ruleDepsOfToutesConditionsMech(node) + } else if (ASTTypes.isSyncMech(node)) { + result = ruleDepsOfSyncMech(node) + } else if (ASTTypes.isGrilleMech(node)) { + result = ruleDepsOfGrilleMech(node) + } else if (ASTTypes.isTauxProgMech(node)) { + result = ruleDepsOfTauxProgMech(node) + } else if (ASTTypes.isDureeMech(node)) { + result = ruleDepsOfDureeMech(node) + } + + if (result === undefined) { + throw new Error( + `This node doesn't have a visitor method defined: ${node.name}` + ) + } + return result +} + +function ruleDepsOfRuleNode( + ruleNode: ASTTypes.RuleNode +): RuleDependencies { + return ruleNode.formule === undefined + ? [] + : ruleDepsOfNode(ruleNode.dottedName, ruleNode.formule) +} + +export function buildRulesDependencies( + parsedRules: ParsedRules +): RulesDependencies { + // This stringPairs thing is necessary because `toPairs` is strictly considering that + // object keys are strings (same for `Object.entries`). Maybe we should build our own + // `toPairs`? + const stringPairs: Array<[string, ASTTypes.RuleNode]> = Object.entries( + parsedRules + ) + const pairs: Array<[Names, ASTTypes.RuleNode]> = stringPairs as Array< + [Names, ASTTypes.RuleNode] + > + + return pairs.map( + ([dottedName, ruleNode]: [Names, ASTTypes.RuleNode]): [ + Names, + RuleDependencies + ] => [dottedName, ruleDepsOfRuleNode(ruleNode)] + ) +} diff --git a/publicodes/source/index.ts b/publicodes/source/index.ts index 744779d33..bfd79a4ea 100644 --- a/publicodes/source/index.ts +++ b/publicodes/source/index.ts @@ -37,6 +37,7 @@ export type EvaluationOptions = Partial<{ export * from './components' export { formatValue, serializeValue } from './format' export { default as translateRules } from './translateRules' +export { default as cyclesLib } from './cyclesLib/index' export * from './types' export { parseRules } export { utils } diff --git a/publicodes/source/mecanisms/arrondi.tsx b/publicodes/source/mecanisms/arrondi.tsx index 0490ba20d..26cf7b72a 100644 --- a/publicodes/source/mecanisms/arrondi.tsx +++ b/publicodes/source/mecanisms/arrondi.tsx @@ -12,7 +12,7 @@ type MecanismRoundProps = { explanation: ArrondiExplanation } -type ArrondiExplanation = { +export type ArrondiExplanation = { value: EvaluatedNode decimals: EvaluatedNode } diff --git a/publicodes/test/cycles.test.js b/publicodes/test/cycles.test.js new file mode 100644 index 000000000..9c5df0c5a --- /dev/null +++ b/publicodes/test/cycles.test.js @@ -0,0 +1,29 @@ +import { expect } from 'chai' +import dedent from 'dedent-js' +import { cyclesInDependenciesGraph } from '../source/cyclesLib/graph' + +describe('Cyclic dependencies detectron 3000 ™', () => { + it('should detect the trivial formule cycle', () => { + const rules = dedent` + a: + formule: a + 1 + ` + const cycles = cyclesInDependenciesGraph(rules) + expect(cycles).to.deep.equal([['a']]) + }) + + it('should detect nested and parallel formule cycles', () => { + const rules = dedent` + a: + formule: b + 1 + b: + formule: c + d + 1 + c: + formule: a + 1 + d: + formule: b + 1 + ` + const cycles = cyclesInDependenciesGraph(rules) + expect(cycles).to.deep.equal([['d', 'c', 'b', 'a']]) + }) +}) diff --git a/yarn.lock b/yarn.lock index 6678031d0..7e2eebeb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -965,6 +965,13 @@ debug "^3.1.0" lodash.once "^4.1.1" +"@dagrejs/graphlib@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-2.1.4.tgz#86c70e4f073844a2f4ada254c8c7b88a6bdacdb1" + integrity sha512-QCg9sL4uhjn468FDEsb/S9hS2xUZSrv/+dApb1Ze5VKO96pTXKNJZ6MGhIpgWkc1TVhbVGH9/7rq/Mf8/jWicw== + dependencies: + lodash "^4.11.1" + "@emotion/is-prop-valid@^0.8.8": version "0.8.8" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" @@ -7853,7 +7860,7 @@ lodash@4.17.15: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== -lodash@^4.0.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0, lodash@~4.17.4: +lodash@^4.0.1, lodash@^4.11.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0, lodash@~4.17.4: version "4.17.20" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==