Skip to content
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
22 changes: 13 additions & 9 deletions src/commands/adopt-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { prepareEnv } from "../env/context.js";
import { normalizeRuleset } from "../engine/dynamic.js";
import type { DynamicStatus } from "../engine/types.js";
import { RESOURCES, configSnippet, fromInformation, slug } from "../resources/registry.js";
import { ReverseResolver } from "../resolve/reverse.js";
import { loadState, saveState, upsert, type State } from "../state/state.js";
import { success, info, warn, out } from "../ui.js";

Expand Down Expand Up @@ -234,6 +235,9 @@ export function adoptGroupCommand(): Command {
}

const now = new Date().toISOString();
// One reverse resolver across the whole (possibly bulk) run — each master-data catalog is
// fetched at most once and reused for every group's numeric-id → logical-sugar rewrite (#52).
const reverse = new ReverseResolver(client);
const results: ResolvedAdoption[] = [];
const reports: Array<{ action: "created" | "updated"; id: number; key: string }> = [];

Expand All @@ -246,7 +250,10 @@ export function adoptGroupCommand(): Command {
}
const fields = GROUP_SPEC.managedFields(resource);

let snippetFields: Record<string, unknown> = fields;
// Reverse-resolve the group's numeric ids to logical sugar for the emitted snippet; the
// captured `dynamic` block (if any) is appended AFTER, so it is not treated as an id field.
const { fields: sugared, todos } = await reverse.sugarFields(fields);
const snippetFields: Record<string, unknown> = sugared;
if (opts.withDynamic) {
const captured = await captureDynamic(id, client);
if (captured) {
Expand All @@ -259,13 +266,10 @@ export function adoptGroupCommand(): Command {
"utf8",
);
}
snippetFields = {
...fields,
dynamic: { status: captured.status, ruleset: { ref: `./${relPath}` } },
};
snippetFields.dynamic = { status: captured.status, ruleset: { ref: `./${relPath}` } };
}
}
const snippet = configSnippet("group", key, snippetFields);
const snippet = configSnippet("group", key, snippetFields, { todos });

if (opts.dryRun) {
results.push({ id, key, fields, snippet });
Expand Down Expand Up @@ -304,9 +308,9 @@ export function adoptGroupCommand(): Command {
}
}

// Grouped, paste-ready config block. configSnippet's per-line FORMAT is unchanged (#52 reworks
// that later) — this only wraps the group of lines under a type comment header, ordered
// parents-before-children where hierarchy is known (--children-of's subtree walk).
// Grouped, paste-ready config block: each snippet is now idiomatic multi-line TS (#52 item A),
// wrapped under a type comment header and ordered parents-before-children where hierarchy is
// known (--children-of's subtree walk).
info(results.length === 1 ? "Config entry:" : "Config entries (paste into your config):");
const block = [`// group`, ...results.map((r) => r.snippet)].join("\n");
process.stdout.write(`${block}\n`);
Expand Down
6 changes: 5 additions & 1 deletion src/commands/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { authedSession } from "../api/session.js";
import { resolveConfig } from "../config.js";
import { prepareEnv } from "../env/context.js";
import { resourceType, configSnippet } from "../resources/registry.js";
import { ReverseResolver } from "../resolve/reverse.js";
import { loadState, saveState, upsert } from "../state/state.js";
import { success, info, warn, out } from "../ui.js";
import { adoptGrantsCommand } from "./adopt-grants.js";
Expand Down Expand Up @@ -47,7 +48,10 @@ export function adoptCommand(): Command {
throw new Error("Could not derive a logical key — pass --key explicitly.");
}
const fields = spec.managedFields(resource);
const snippet = configSnippet(type, key, fields);
// Reverse-resolve numeric ids (campusId/groupTypeId/groupStatusId) to logical sugar so the
// emitted snippet is portable and human-readable; unresolved ids stay numeric + a TODO (#52).
const { fields: sugared, todos } = await new ReverseResolver(client).sugarFields(fields);
const snippet = configSnippet(type, key, sugared, { todos });

if (opts.dryRun) {
info(`Would adopt ${type} #${id} as "${key}". Generated config entry:`);
Expand Down
220 changes: 159 additions & 61 deletions src/config/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
* The context is injected (no global state), so blueprints are just functions
* and loops, and the whole thing is trivially testable without file I/O.
*/
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
import type { DesiredResource, DynamicSpec, DynamicStatus } from "../engine/types.js";
import type { DomainType } from "../permissions/grants.js";
import type { DesiredPermission, Grant } from "../permissions/types.js";
import { isRef, ref, refKey, type Ref } from "../resolve/refs.js";
import { knownFields } from "../resources/registry.js";
import { conventionalRulesetRef, knownFields } from "../resources/registry.js";
import { warn } from "../ui.js";
// Re-exported so a config file can pull the query DSL from the same module as
// `ConfigContext`: `import { q, churchQuery } from "../../src/config/context.js"`.
Expand All @@ -27,10 +29,68 @@ export { ref } from "../resolve/refs.js";

const DYNAMIC_STATUSES = ["active", "inactive", "manual", "none"] as const;

/** This module's own filesystem path — used to skip our own frames when locating a user call site. */
const SELF_FILE = fileURLToPath(import.meta.url);

/**
* Best-effort source location of the user's `ct.*(...)` call, for located config errors/warnings (#52).
* Walks `new Error().stack` for the first frame outside this module, node internals, and node_modules.
* jiti transpiles the user's `.ts` config but maps stack frames back to the ORIGINAL file + line
* (verified against the real loader), so the frame yields the author's actual location. Returns
* `basename:line` (e.g. `ct.config.ts:42`), or `undefined` when no user frame is identifiable — callers
* then omit the location rather than crash (V8-only `.stack`; a runtime without it degrades gracefully).
*/
function captureCallSite(): string | undefined {
const stack = new Error().stack;
if (typeof stack !== "string") return undefined;
for (const raw of stack.split("\n").slice(1)) {
const line = raw.trim();
if (!line.startsWith("at ")) continue;
// `at fn (PATH:LINE:COL)` (function named) or `at PATH:LINE:COL` (top-level/anonymous).
const m = /\((.+):(\d+):(\d+)\)$/.exec(line) ?? /^at\s+(.+):(\d+):(\d+)$/.exec(line);
if (!m) continue;
let file = m[1]!;
if (file.startsWith("file://")) file = fileURLToPath(file);
if (file === SELF_FILE) continue; // our own wrapper / helper frames
if (file.startsWith("node:")) continue; // node internals
if (file.includes("/node_modules/")) continue; // jiti & other deps
return `${basename(file)}:${m[2]}`;
}
return undefined;
}

/** Prefix a config error/warning message with its source location when known (#52). */
function located(location: string | undefined, message: string): string {
return location ? `${location} — ${message}` : message;
}

/**
* Prefix a thrown eval-time config error with its user call site (#52), once. Mutating `.message`
* (rather than wrapping) keeps the original stack; the `__ctLocated` marker guards against a second
* prefix if the same error somehow passes through another wrapper.
*/
function relocate(err: unknown, location: string | undefined): unknown {
if (location && err instanceof Error && !(err as { __ctLocated?: boolean }).__ctLocated) {
err.message = located(location, err.message);
(err as { __ctLocated?: boolean }).__ctLocated = true;
}
return err;
}

/**
* A group's `dynamic` (auto-group) declaration (#52 item B). Three interchangeable forms:
* - `true` — dynamic with `status: "active"` and the conventional `./rulesets/<key>.json` ruleset ref.
* - `"<path>.json"` — dynamic with `status: "active"` and an explicit ruleset-file ref.
* - `{ status, ruleset }` — the explicit form (a RuleSet object, a `{ ref }`, or a typed-query build).
*/
export type DynamicInput = boolean | string | { status: DynamicStatus; ruleset: unknown };

export interface ResourceInput {
key: string;
/** Ordering hint: apply this resource after `parent`. A dependency edge only — NOT managed hierarchy. */
parent?: string;
/** Auto-group config (opt-in; omit for a plain group). Only valid on `ct.group(...)`. See {@link DynamicInput}. */
dynamic?: DynamicInput;
/**
* Managed parent groups (group→group hierarchy). Opt-in: omit to leave a group's hierarchy
* unmanaged; `[]` means "managed with no parents". Each key must reference a group declared
Expand Down Expand Up @@ -129,7 +189,42 @@ export interface ConfigContext {

export type ConfigModule = (ct: ConfigContext) => void | Promise<void>;

function toDesired(type: string, input: ResourceInput): DesiredResource {
/**
* Eval-time desugaring of a group's `dynamic` field (#52 item B) — the engine is untouched; every
* form collapses to the same {@link DynamicSpec}. Three authoring forms:
* - `dynamic: true` → `{ status: "active", ruleset: { ref: "./rulesets/<key>.json" } }`
* - `dynamic: "<path>.json"` → `{ status: "active", ruleset: { ref: "<path>" } }`
* - `dynamic: { status, ruleset }` (explicit) — validated as before.
* Returns `undefined` for `undefined` (opt-in: not a dynamic group). Anything else throws.
*/
function desugarDynamic(type: string, key: string, dynamic: unknown): DynamicSpec | undefined {
if (dynamic === undefined) return undefined;
if (type !== "group") throw new Error(`${type} "${key}": "dynamic" is only valid on a group.`);
if (dynamic === true) {
return { status: "active", ruleset: { ref: conventionalRulesetRef(key) } };
}
if (typeof dynamic === "string") {
if (!dynamic.endsWith(".json"))
throw new Error(
`group "${key}": "dynamic" as a string must be a path to a .json ruleset file ` +
`(e.g. "./rulesets/${key}.json"), got ${JSON.stringify(dynamic)}.`,
);
return { status: "active", ruleset: { ref: dynamic } };
}
if (dynamic === null || typeof dynamic !== "object") {
throw new Error(
`group "${key}": "dynamic" must be true, a "<path>.json" string, or an object with { status, ruleset }.`,
);
}
const d = dynamic as Record<string, unknown>;
if (!DYNAMIC_STATUSES.includes(d.status as DynamicStatus))
throw new Error(`group "${key}": "dynamic.status" must be one of ${DYNAMIC_STATUSES.join(", ")}.`);
if (d.ruleset == null || typeof d.ruleset !== "object")
throw new Error(`group "${key}": "dynamic.ruleset" must be a RuleSet object or a { ref } reference.`);
return { status: d.status as DynamicStatus, ruleset: d.ruleset };
}

function toDesired(type: string, input: ResourceInput, location?: string): DesiredResource {
const { key, parent, parents, dependsOn = [], preventDestroy, dynamic, ...fields } = input;
if (!key || typeof key !== "string") {
throw new Error(`${type} declaration is missing a string "key".`);
Expand Down Expand Up @@ -177,29 +272,17 @@ function toDesired(type: string, input: ResourceInput): DesiredResource {
// field still passes through into `fields` unchanged (unrecognised fields have always been sent
// as-is); this only surfaces the mistake instead of leaving it silently un-diffed forever. The
// allowlist comes from `knownFields` (the registry's own `managedFields`), never hand-copied, so
// it can't drift from what `adopt`/`plan`/`apply` actually read and write. Issue #52 will add
// file:line locations to this warning — not built here.
// it can't drift from what `adopt`/`plan`/`apply` actually read and write. The `location` prefix
// (#52) points the author at the exact config file + line of the offending declaration.
const allowed = knownFields(type);
for (const fieldKey of Object.keys(fields)) {
if (!allowed.has(fieldKey)) {
warn(`${type} "${key}": unknown field "${fieldKey}" (ignored)`);
warn(located(location, `${type} "${key}": unknown field "${fieldKey}" (ignored)`));
}
}
// `dynamic` is a synthetic field for auto-groups, handled separately from the plain diffed
// field bag. Opt-in: `undefined` means "not a dynamic group" (mirrors `parents`).
let dynamicSpec: DynamicSpec | undefined;
if (dynamic !== undefined) {
if (type !== "group") throw new Error(`${type} "${key}": "dynamic" is only valid on a group.`);
if (dynamic == null || typeof dynamic !== "object") {
throw new Error(`group "${key}": "dynamic" must be an object with { status, ruleset }.`);
}
const d = dynamic as Record<string, unknown>;
if (!DYNAMIC_STATUSES.includes(d.status as DynamicStatus))
throw new Error(`group "${key}": "dynamic.status" must be one of ${DYNAMIC_STATUSES.join(", ")}.`);
if (d.ruleset == null || typeof d.ruleset !== "object")
throw new Error(`group "${key}": "dynamic.ruleset" must be a RuleSet object or a { ref } reference.`);
dynamicSpec = { status: d.status as DynamicStatus, ruleset: d.ruleset };
}
const dynamicSpec = desugarDynamic(type, key, dynamic);
// `parent` is an ordering hint only — a dependency edge, never a diffed/managed field
// (its pre-hierarchy meaning; a `parent` may point at a campus). Group hierarchy is
// managed opt-in via `parents`: `undefined` → unmanaged, `[]` → managed with no parents.
Expand Down Expand Up @@ -261,55 +344,70 @@ export function createContext(): {
const define =
(type: string) =>
(input: ResourceInput): void => {
const resource = toDesired(type, input);
if (seen.has(resource.key)) {
throw new Error(`Duplicate logical key "${resource.key}" in config.`);
// Capture the user's call site FIRST (top of the wrapper = the frame is the author's
// `ct.<type>({...})` call), then locate any eval-time error or unknown-field warning it raises (#52).
const location = captureCallSite();
try {
const resource = toDesired(type, input, location);
if (seen.has(resource.key)) {
throw new Error(`Duplicate logical key "${resource.key}" in config.`);
}
seen.add(resource.key);
resources.push(resource);
} catch (err) {
throw relocate(err, location);
}
seen.add(resource.key);
resources.push(resource);
};
const definePermissionInner = (domainType: DomainType, input: PermissionInput): void => {
if (typeof input.key !== "string" || !input.key)
throw new Error(`${domainType} declaration missing a string "key".`);
const domainId = resolveDomainInput(domainType, input);
if (!Array.isArray(input.grants))
throw new Error(`${domainType} "${input.key}": "grants" must be an array.`);
for (const g of input.grants) {
const right = typeof g === "string" ? g : g?.right;
if (typeof right !== "string" || !right.includes(":"))
throw new Error(
`${domainType} "${input.key}": each grant must be a "module:right" string or { right, scope }.`,
);
if (typeof g === "object") {
if (!Array.isArray(g.scope))
throw new Error(`${domainType} "${input.key}": scoped grant needs "scope": (string | number)[].`);
// Each entry is a logical group key, or a raw numeric dataId (#49 escape hatch — for scope
// dimensions that aren't groups, e.g. security levels, which have no logical/managed form).
for (const s of g.scope) {
if (typeof s === "string" ? s.length === 0 : typeof s !== "number")
throw new Error(
`${domainType} "${input.key}": scope entries must be a non-empty string (logical group key) or a number (raw dataId), got ${JSON.stringify(s)}.`,
);
}
}
}
if (seen.has(input.key)) throw new Error(`Duplicate logical key "${input.key}" in config.`);
seen.add(input.key);
// Duplicate-target guard, keyed by the canonical domain string (numeric id or Ref key). This
// catches obvious eval-time collisions early; the authoritative check runs post-resolution in
// buildPermissionPlan (two different refs, or a ref and a number, can resolve to the same id).
const domainKey = `${domainType}:${domainKeyPart(domainId)}`;
const existingKey = seenDomains.get(domainKey);
if (existingKey) {
const label = typeof domainId === "number" ? `#${domainId}` : refKey(domainId);
throw new Error(
`Duplicate permission target: ${domainType} ${label} is declared by both "${existingKey}" and "${input.key}". Merge their grants into one declaration.`,
);
}
seenDomains.set(domainKey, input.key);
permissions.push({ key: input.key, domainType, domainId, grants: input.grants });
};
const definePermission =
(domainType: DomainType) =>
(input: PermissionInput): void => {
if (typeof input.key !== "string" || !input.key)
throw new Error(`${domainType} declaration missing a string "key".`);
const domainId = resolveDomainInput(domainType, input);
if (!Array.isArray(input.grants))
throw new Error(`${domainType} "${input.key}": "grants" must be an array.`);
for (const g of input.grants) {
const right = typeof g === "string" ? g : g?.right;
if (typeof right !== "string" || !right.includes(":"))
throw new Error(
`${domainType} "${input.key}": each grant must be a "module:right" string or { right, scope }.`,
);
if (typeof g === "object") {
if (!Array.isArray(g.scope))
throw new Error(`${domainType} "${input.key}": scoped grant needs "scope": (string | number)[].`);
// Each entry is a logical group key, or a raw numeric dataId (#49 escape hatch — for scope
// dimensions that aren't groups, e.g. security levels, which have no logical/managed form).
for (const s of g.scope) {
if (typeof s === "string" ? s.length === 0 : typeof s !== "number")
throw new Error(
`${domainType} "${input.key}": scope entries must be a non-empty string (logical group key) or a number (raw dataId), got ${JSON.stringify(s)}.`,
);
}
}
}
if (seen.has(input.key)) throw new Error(`Duplicate logical key "${input.key}" in config.`);
seen.add(input.key);
// Duplicate-target guard, keyed by the canonical domain string (numeric id or Ref key). This
// catches obvious eval-time collisions early; the authoritative check runs post-resolution in
// buildPermissionPlan (two different refs, or a ref and a number, can resolve to the same id).
const domainKey = `${domainType}:${domainKeyPart(domainId)}`;
const existingKey = seenDomains.get(domainKey);
if (existingKey) {
const label = typeof domainId === "number" ? `#${domainId}` : refKey(domainId);
throw new Error(
`Duplicate permission target: ${domainType} ${label} is declared by both "${existingKey}" and "${input.key}". Merge their grants into one declaration.`,
);
const location = captureCallSite();
try {
definePermissionInner(domainType, input);
} catch (err) {
throw relocate(err, location);
}
seenDomains.set(domainKey, input.key);
permissions.push({ key: input.key, domainType, domainId, grants: input.grants });
};
// Every type emitted here MUST have an apply tier in engine/graph.ts TYPE_TIER
// (locked by tests/context.test.ts), else computePlan rejects it at plan time.
Expand Down
Loading
Loading