/**
* modules/rules.ts — Rules as a Papyrus-native registered module
* (step 5, continued, of the incremental refactor in
* reducing-papyrus-consumer-change-amplification-with-modules--pvdo).
*
* rules.injectable is intentionally NOT registered here even though its operation name
* starts with "rules.": its implementation requires tasks.active() (the current Task
* Focus) to decide which scoped rules apply, a genuine cross-module concern. It stays a
* composition-root operation in src/service.ts rather than importing Tasks internals
* into this module or introducing a premature "modules call each other through the
* registry" convention.
*/
import { type Artifact, summarizeArtifact } from "../artifact/artifact.ts";
import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
import type { ArtifactStore } from "../artifact/artifact-store.ts";
import type { OperationDefinition } from "../module-registry.ts";
import type { ProjectRegistryStore } from "../project-registry/project-registry-store.ts";
import {
addRuleGroup,
addRuleProject,
assignRuleProject,
createRule,
gateTaskWithRule,
listRules,
previewRule,
removeRuleGroup,
removeRuleProject,
replaceRuleGroups,
replaceRuleProjects,
ruleCombinedLength,
ruleCombinedLengthWarning,
ruleScope,
setRuleGlobal,
setRuleNone,
showRule,
transitionRule,
updateRule,
} from "../rules/rules-service.ts";
import type { ScopeGroupStore } from "../scope-group/scope-group-store.ts";
import { type OperationInput, optionalBoolean, optionalNumber, optionalString, string } from "./operation-input.ts";
const MODULE_ID = "rules";
const eventContext = (input: OperationInput) => ({
actor: optionalString(input, "actor"),
source: optionalString(input, "source"),
sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
});
const artifactFilter = (input: OperationInput) => ({
status: optionalString(input, "status"),
text: optionalString(input, "text"),
limit: optionalNumber(input, "limit"),
projectRoot: optionalString(input, "project_root"),
});
/**
* Adds a rule's own combinedLength (and, past the soft target, a non-blocking warning) to any
* response shaped as its own Artifact -- additive fields alongside every existing one, so a
* caller reading .id/.title/... unchanged still works. Removes the need for a manual
* len(condition)+len(action)+len(body) count before every rules.create/update call.
*/
function withRuleLengthInfo(rule: Artifact): Artifact & { combinedLength: number; warning?: string } {
const combinedLength = ruleCombinedLength(
typeof rule.extra.condition === "string" ? rule.extra.condition : undefined,
typeof rule.extra.action === "string" ? rule.extra.action : undefined,
rule.body,
);
const warning = ruleCombinedLengthWarning(combinedLength);
return { ...rule, combinedLength, ...(warning === undefined ? {} : { warning }) };
}
/** Registers every rules.* operation except rules.injectable (see module comment). Behavior is unchanged from the prior inline handlers in src/service.ts. */
/** This module's own operation names, the single source of truth src/service.ts's EXPECTED_OPERATION_NAMES spreads in rather than re-listing by hand. rules.injectable is deliberately absent -- see the module comment above. */
export const RULES_OPERATION_NAMES = [
"rules.create",
"rules.list",
"rules.show",
"rules.preview",
"rules.enable",
"rules.disable",
"rules.gate",
"rules.assign_project",
"rules.scope",
"rules.set_global",
"rules.set_none",
"rules.add_project",
"rules.remove_project",
"rules.replace_projects",
"rules.add_group",
"rules.remove_group",
"rules.replace_groups",
"rules.update",
] as const;
export function rulesOperations(
artifacts: ArtifactStore,
scopes: ArtifactScopeStore,
registry: ProjectRegistryStore,
scopeGroups: ScopeGroupStore,
): OperationDefinition[] {
const define = (name: string, execute: (input: Input) => Output): OperationDefinition => ({
name,
moduleId: MODULE_ID,
execute,
});
return [
define("rules.create", (input: OperationInput) =>
withRuleLengthInfo(
createRule(
artifacts,
scopes,
{
title: string(input, "title"),
body: optionalString(input, "body"),
condition: optionalString(input, "condition"),
action: optionalString(input, "rule_action") ?? optionalString(input, "governance_action"),
severity: optionalString(input, "severity") as "block" | "warn" | "info" | undefined,
subtype: optionalString(input, "subtype"),
labels: input.labels as string[] | undefined,
extra: input.extra as Record | undefined,
activation: input.activation,
activationEnabled: optionalBoolean(input, "activation_enabled"),
templateId: optionalString(input, "template_id") ?? optionalString(input, "templateId"),
projectRoot: optionalString(input, "project_root"),
projectReferences: input.projects as string[] | undefined,
},
eventContext(input),
registry,
),
),
),
define("rules.list", (input: OperationInput) => {
const rules = listRules(artifacts, scopes, artifactFilter(input));
return optionalBoolean(input, "full") === true ? rules : rules.map(summarizeArtifact);
}),
define("rules.show", (input: OperationInput) => withRuleLengthInfo(showRule(artifacts, string(input, "id")))),
define("rules.preview", (input: OperationInput) => {
const rule = showRule(artifacts, string(input, "id"));
const combinedLength = ruleCombinedLength(
typeof rule.extra.condition === "string" ? rule.extra.condition : undefined,
typeof rule.extra.action === "string" ? rule.extra.action : undefined,
rule.body,
);
const warning = ruleCombinedLengthWarning(combinedLength);
return { preview: previewRule(artifacts, string(input, "id")), combinedLength, ...(warning === undefined ? {} : { warning }) };
}),
define("rules.enable", (input: OperationInput) => transitionRule(artifacts, string(input, "id"), "enable", eventContext(input))),
define("rules.disable", (input: OperationInput) => transitionRule(artifacts, string(input, "id"), "disable", eventContext(input))),
define("rules.gate", (input: OperationInput) =>
gateTaskWithRule(artifacts, string(input, "id"), string(input, "task_id"), eventContext(input)),
),
define("rules.assign_project", (input: OperationInput) =>
assignRuleProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root")),
),
define("rules.scope", (input: OperationInput) => ruleScope(artifacts, scopes, string(input, "id"))),
define("rules.set_global", (input: OperationInput) => setRuleGlobal(artifacts, scopes, string(input, "id"))),
define("rules.set_none", (input: OperationInput) => setRuleNone(artifacts, scopes, string(input, "id"))),
define("rules.add_group", (input: OperationInput) =>
addRuleGroup(artifacts, scopes, scopeGroups, string(input, "id"), string(input, "group")),
),
define("rules.remove_group", (input: OperationInput) =>
removeRuleGroup(artifacts, scopes, scopeGroups, string(input, "id"), string(input, "group")),
),
define("rules.replace_groups", (input: OperationInput) =>
replaceRuleGroups(artifacts, scopes, scopeGroups, string(input, "id"), (input.groups as string[] | undefined) ?? []),
),
define("rules.add_project", (input: OperationInput) =>
addRuleProject(artifacts, scopes, registry, string(input, "id"), string(input, "project")),
),
define("rules.remove_project", (input: OperationInput) =>
removeRuleProject(artifacts, scopes, registry, string(input, "id"), string(input, "project")),
),
define("rules.replace_projects", (input: OperationInput) =>
replaceRuleProjects(artifacts, scopes, registry, string(input, "id"), (input.projects as string[] | undefined) ?? []),
),
define("rules.update", (input: OperationInput) =>
withRuleLengthInfo(
updateRule(
artifacts,
string(input, "id"),
{
title: optionalString(input, "title"),
body: optionalString(input, "body"),
labels: input.labels as string[] | undefined,
activation: input.activation,
activationEnabled: optionalBoolean(input, "activation_enabled"),
},
eventContext(input),
),
),
),
];
}