Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support and tests for capability specification via provides #80

Merged
merged 3 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/blueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export default class Blueprint extends Contract {
(accumulator: any, value, type) => {
const selector = {
cardinality: parse(value.cardinality || value) as any,
filter: value.filter,
// Array has its own `filter` function, which we need to ignore
filter: Array.isArray(value) ? undefined : value.filter,
type: value.type || type,
version: value.version,
};
Expand Down
274 changes: 116 additions & 158 deletions lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,8 @@ export default class Contract {
// the list of hashes we should check against.
const match = matches(omit(matcher.raw.data, ['slug', 'version']));
const versionMatch = matcher.raw.data.version;
if (contract.raw.capabilities) {
for (const capability of contract.raw.capabilities) {
if (contract.raw.provides) {
for (const capability of contract.raw.provides) {
if (match(capability)) {
if (versionMatch) {
if (valid(capability.version) && validRange(versionMatch)) {
Expand Down Expand Up @@ -1032,16 +1032,105 @@ export default class Contract {
[] as Contract[],
);
}

private isRequirementSatisfied(
requirement: Contract,
options: { types?: Set<string> } = {},
): boolean {
// Utilities
const shouldEvaluateType = (type: string) =>
options.types ? options.types.has(type) : true;

/**
* @summary Check if a matcher is satisfied
* @function
* @public
*
* @param {Object} matcher - matcher contract
* @returns {Boolean} whether the matcher is satisfied
*
* @example
* const matcher = Contract.createMatcher({
* type: 'sw.os',
* slug: 'debian'
* })
*
* if (hasMatch(matcher)) {
* console.log('This matcher is satisfied!')
* }
*/
const hasMatch = (matcher: Contract): boolean => {
// TODO: Write a function similar to findContracts
// that stops as soon as it finds one match
return (
this.findChildren(matcher).length > 0 ||
this.findChildrenWithCapabilities(matcher).length > 0
);
};

if (requirement.raw.operation === 'or') {
// (3.1) Note that we should only consider disjuncts
// of types we are allowed to check. We can make
// such transformation here, so we can then consider
// the disjunction as fulfilled if there are no
// remaining disjuncts.
const disjuncts = filter(requirement.raw.data.getAll(), (disjunct) => {
return shouldEvaluateType(disjunct.raw.data.type);
});
// (3.2) An empty disjuction means that this particular
// requirement is fulfilled, so we can carry on.
// A disjunction naturally contains a list of further
// requirements we need to check for. If at least one
// of the members is fulfilled, we can proceed with
// next requirement.
if (disjuncts.length === 0 || some(disjuncts, hasMatch)) {
return true;
}
Comment on lines +1077 to +1088
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm less confident on this combination but it should work (I may have done the conversion incorrectly, but the conversion into a single every should be possible)

Suggested change
const disjuncts = filter(requirement.raw.data.getAll(), (disjunct) => {
return shouldEvaluateType(disjunct.raw.data.type);
});
// (3.2) An empty disjuction means that this particular
// requirement is fulfilled, so we can carry on.
// A disjunction naturally contains a list of further
// requirements we need to check for. If at least one
// of the members is fulfilled, we can proceed with
// next requirement.
if (disjuncts.length === 0 || some(disjuncts, hasMatch)) {
return true;
}
// (3.2) An empty disjuction means that this particular
// requirement is fulfilled, so we can carry on.
// A disjunction naturally contains a list of further
// requirements we need to check for. If at least one
// of the members is fulfilled, we can proceed with
// next requirement.
if (every(requirement.raw.data.getAll(), (disjunct) => {
return !shouldEvaluateType(disjunct.raw.data.type) || !hasMatch(disjunct)
})) {
return true;
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't work unfortunately, in order for the requirement to be accepted we need either:

  • there are no requirements to evaluate
  • if there are requirements to evaluate, there is at least one match

The closest thing would be

if (
	every(requirement.raw.data.getAll(), (disjunct) => {
		return (
			!shouldEvaluateType(disjunct.raw.data.type) || hasMatch(disjunct)
		);
	})
) {
	return true;
}

however this is not the same, because if shouldEvaluateType(disjunct.raw.data.type) is always true, this is equivalent to writing

if (
	every(requirement.raw.data.getAll(), (disjunct) => {
		return hasMatch(disjunct);
	})
) {
	return true;
}

which is the opposite of what we want

I'm not sure there is an equivalent using a single some/every

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think the problem is we need every(disjuncts, !shouldEvaluateType) || some(disjuncts, shouldEvaluateType && hasMatch) and those can't be combined so what we currently have is the best we can manage with functional. However it can be done via a for-loop with return to short-circuit the some case but keeping the every case requiring an entire loop:

Suggested change
const disjuncts = filter(requirement.raw.data.getAll(), (disjunct) => {
return shouldEvaluateType(disjunct.raw.data.type);
});
// (3.2) An empty disjuction means that this particular
// requirement is fulfilled, so we can carry on.
// A disjunction naturally contains a list of further
// requirements we need to check for. If at least one
// of the members is fulfilled, we can proceed with
// next requirement.
if (disjuncts.length === 0 || some(disjuncts, hasMatch)) {
return true;
}
// (3.2) An empty disjuction means that this particular
// requirement is fulfilled, so we can carry on.
// A disjunction naturally contains a list of further
// requirements we need to check for. If at least one
// of the members is fulfilled, we can proceed with
// next requirement.
let noneToEvaluate = true;
for (const disjunct of (requirement.raw.data.getAll()) {
if (shouldEvaluateType(disjunct.raw.data.type)) {
noneToEvaluate = false
if (hasMatch(disjunct)) {
return true;
}
}
}
return noneToEvaluate;

Fwiw I don't think this is worth it unless it's a performance bottleneck but since I dug into how to do it I thought I'd share

Copy link
Contributor Author

@pipex pipex Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does sacrifice a bit of readability, I suspect at some point we'll need to work on the efficiency of some of the operations at which point we can review. Thank you for looking into it!

// (3.3) If no members were fulfilled, then we know
// that this requirement was not fullfilled, so it will be returned
return false;
} else if (requirement.raw.operation === 'not') {
// (3.4) Note that we should only consider disjuncts
// of types we are allowed to check. We can make
// such transformation here, so we can then consider
// the disjunction as fulfilled if there are no
// remaining disjuncts.
// (3.5) We fail the requirement if the set of negated
// disjuncts is not empty, and we have at least one of
// them in the context.
if (
some(requirement.raw.data.getAll(), (disjunct) => {
return (
shouldEvaluateType(disjunct.raw.data.type) && hasMatch(disjunct)
);
})
) {
return false;
}
return true;
}
// (4) If we should evaluate this requirement and it is not fullfilled
// it will be returned
if (
shouldEvaluateType(requirement.raw.data.type) &&
!hasMatch(requirement)
) {
return false;
}

return true;
}

/**
* @summary Check if a child contract is satisfied when applied to this contract
* @summary Get a list of child requirements that are not satisfied by this contract
* @function
* @name module:contrato.Contract#satisfiesChildContract
* @public
*
* @param {Object} contract - child contract
* @param {Object} [options] - options
* @param {Set} [options.types] - the types to consider (all by default)
* @returns {Boolean} whether the contract is satisfied
* @returns list of unsatisfied requirements
*
* @example
* const contract = new Contract({ ... })
Expand Down Expand Up @@ -1087,96 +1176,33 @@ export default class Contract {
*/
getNotSatisfiedChildRequirements(
contract: Contract,
options: { types: Set<string> } = { types: new Set() },
): any[] {
const conjuncts = reduce(
contract.getChildren(),
(accumulator, child) => {
return accumulator.concat(
child.metadata.requirements.compiled.getAll(),
);
},
contract.metadata.requirements.compiled.getAll(),
);
options: { types?: Set<string> } = {},
) {
const conjuncts: Contract[] = contract.metadata.requirements.compiled
.getAll()
.concat(
contract
.getChildren()
.flatMap((child) => child.metadata.requirements.compiled.getAll()),
);
// (1) If the top level list of conjuncts is empty,
// then we can assume the requirements are fulfilled
// and stop without doing any further computations.
if (conjuncts.length === 0) {
return [];
}
// Utilities
const shouldEvaluateType = (type: string) =>
options.types ? options.types.has(type) : true;

const requirements: any[] = [];
/**
* @summary Check if a matcher is satisfied
* @function
* @public
*
* @param {Object} matcher - matcher contract
* @returns {Boolean} whether the matcher is satisfied
*
* @example
* const matcher = Contract.createMatcher({
* type: 'sw.os',
* slug: 'debian'
* })
*
* if (hasMatch(matcher)) {
* console.log('This matcher is satisfied!')
* }
*/
const hasMatch = (matcher: Contract): boolean => {
// TODO: Write a function similar to findContracts
// that stops as soon as it finds one match
return (
this.findChildren(matcher).length > 0 ||
this.findChildrenWithCapabilities(matcher).length > 0
);
};
// (2) The requirements are specified as a list of objects,
// so lets iterate through those.
// This function uses a for loop instead of a more functional
// construct for performance reasons, given that we can freely
// break out of the loop as soon as possible.
for (const conjunct of conjuncts) {
if (conjunct.raw.operation === 'or') {
// (3.1) Note that we should only consider disjuncts
// of types we are allowed to check. We can make
// such transformation here, so we can then consider
// the disjunction as fulfilled if there are no
// remaining disjuncts.
const disjuncts = filter(conjunct.raw.data.getAll(), (disjunct) => {
return shouldEvaluateType(disjunct.raw.data.type);
});
// (3.2) An empty disjuction means that this particular
// requirement is fulfilled, so we can carry on.
// A disjunction naturally contains a list of further
// requirements we need to check for. If at least one
// of the members is fulfilled, we can proceed with
// next requirement.
if (disjuncts.length === 0 || some(map(disjuncts, hasMatch))) {
continue;
}
// (3.3) If no members were fulfilled, then we know
// that this requirement was not fullfilled, so it will be returned
requirements.push(conjunct.raw.data);
}
// (4) If we should evaluate this requirement and it is not fullfilled
// it will be returned
if (shouldEvaluateType(conjunct.raw.data.type) && !hasMatch(conjunct)) {
requirements.push(conjunct.raw.data);
} else if (!shouldEvaluateType(conjunct.raw.data.type)) {
// If this requirement is not evaluated, because of missing contracts,
// it will also be returned.
requirements.push(conjunct.raw.data);
}
}
return conjuncts
.filter((conjunct) => !this.isRequirementSatisfied(conjunct, options))
.map((conjunct) => conjunct.raw.data);
// (5) If we reached this far, then it means that all the
// requirements were checked, and they were all satisfied,
// so this is good to go!
return requirements;
}
/**
* @summary Check if a child contract is satisfied when applied to this contract
Expand Down Expand Up @@ -1235,97 +1261,29 @@ export default class Contract {
contract: Contract,
options: { types?: Set<string> } = {},
): boolean {
const conjuncts = reduce(
contract.getChildren(),
(accumulator, child) => {
return accumulator.concat(
child.metadata.requirements.compiled.getAll(),
);
},
contract.metadata.requirements.compiled.getAll(),
);
const conjuncts: Contract[] = contract.metadata.requirements.compiled
.getAll()
.concat(
contract
.getChildren()
.flatMap((child) => child.metadata.requirements.compiled.getAll()),
);

// (1) If the top level list of conjuncts is empty,
// then we can assume the requirements are fulfilled
// and stop without doing any further computations.
if (conjuncts.length === 0) {
return true;
}
// Utilities
const shouldEvaluateType = (type: string) =>
options.types ? options.types.has(type) : true;
/**
* @summary Check if a matcher is satisfied
* @function
* @public
*
* @param {Object} matcher - matcher contract
* @returns {Boolean} whether the matcher is satisfied
*
* @example
* const matcher = Contract.createMatcher({
* type: 'sw.os',
* slug: 'debian'
* })
*
* if (hasMatch(matcher)) {
* console.log('This matcher is satisfied!')
* }
*/
const hasMatch = (matcher: Contract): boolean =>
// TODO: Write a function similar to findContracts
// that stops as soon as it finds one match
this.findChildren(matcher).length > 0;

// (2) The requirements are specified as a list of objects,
// so lets iterate through those.
// This function uses a for loop instead of a more functional
// construct for performance reasons, given that we can freely
// break out of the loop as soon as possible.
for (const conjunct of conjuncts) {
if (conjunct.raw.operation === 'or') {
// (3.1) Note that we should only consider disjuncts
// of types we are allowed to check. We can make
// such transformation here, so we can then consider
// the disjunction as fulfilled if there are no
// remaining disjuncts.
const disjuncts = filter(conjunct.raw.data.getAll(), (disjunct) => {
return shouldEvaluateType(disjunct.raw.data.type);
});
// (3.2) An empty disjuction means that this particular
// requirement is fulfilled, so we can carry on.
// A disjunction naturally contains a list of further
// requirements we need to check for. If at least one
// of the members is fulfilled, we can proceed with
// next requirement.
if (disjuncts.length === 0 || some(map(disjuncts, hasMatch))) {
continue;
}
// (3.3) If no members were fulfilled, then we know
// the whole contract is unsatisfied, so there's no
// reason to keep checking the remaining requirements.
return false;
} else if (conjunct.raw.operation === 'not') {
// (3.4) Note that we should only consider disjuncts
// of types we are allowed to check. We can make
// such transformation here, so we can then consider
// the disjunction as fulfilled if there are no
// remaining disjuncts.
const disjuncts = filter(conjunct.raw.data.getAll(), (disjunct) => {
return shouldEvaluateType(disjunct.raw.data.type);
});
// (3.5) We fail the requirement if the set of negated
// disjuncts is not empty, and we have at least one of
// them in the context.
if (disjuncts.length > 0 && some(map(disjuncts, hasMatch))) {
return false;
}
continue;
}
// (4) If we reached this point, then we know we're dealing
// with a conjunct from the top level *AND* operator.
// Since a logical "and" means that all elements must be
// fulfilled, we can return right away if one of these
// was not satisfied.
if (shouldEvaluateType(conjunct.raw.data.type) && !hasMatch(conjunct)) {
// (3-4) stop looking if an unsatisfied requirement is found
if (!this.isRequirementSatisfied(conjunct, options)) {
return false;
}
}
Expand Down
4 changes: 4 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import Blueprint from './blueprint';
import Universe from './universe';
import { buildTemplate } from './partials';

// this is exported as is one of the return types of
// Contract.getNotSatisfiedChildRequirements
// TODO: remove this comment once the library has correct typings
export { default as ObjectSet } from './object-set';
export {
BlueprintLayout,
ContractObject,
Expand Down
Loading