Spaces:
Runtime error
Runtime error
Aryan Mishra
feat: initial commit - Multilingual ABSA project setup with 6 phases, 36 requirements
a6b96c2 | ; | |
| /** | |
| * Capability Writer β ADR-1213 write-side inverse of capability-state resolver. | |
| * | |
| * Exports: | |
| * setCapabilityState(cwd, runtimeConfigDir, desired, opts?) | |
| * β { capabilities: CapabilityStateEntry[], warnings: string[] } | |
| * cmdCapabilitySet(cwd, runtimeConfigDir, capId, options, raw) | |
| * | |
| * Projection rules (three axes: install, surface, config): | |
| * - enabled axis: mutates .gsd-surface.json via readSurface/writeSurface | |
| * - gates axis: mutates .planning/config.json via setConfigValues (batched) | |
| * - materialize: optionally calls applySurface to write skill files | |
| * - re-resolve: always calls resolveCapabilityRuntimeState for the return value | |
| * | |
| * Dependencies (leaf modules only β no circular risk): | |
| * - ./io.cjs (output, error) | |
| * - ./capability-state.cjs (resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir) | |
| * - ./surface.cjs (readSurface, writeSurface, applySurface) | |
| * - ./install-profiles.cjs (readActiveProfile) | |
| * - ./config.cjs (setConfigValues) | |
| * - ./runtime-artifact-layout.cjs (resolveRuntimeArtifactLayout) | |
| * - capability-registry.cjs (loaded at call time) | |
| */ | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const ioMod = require("./io.cjs"); | |
| const { output: coreOutput, error: coreError } = ioMod; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const capabilityStateMod = require("./capability-state.cjs"); | |
| const { resolveCapabilityRuntimeState, _resolveManifest, _resolveCommandsGsdDir } = capabilityStateMod; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const surfaceMod = require("./surface.cjs"); | |
| const { readSurface, writeSurface, applySurface } = surfaceMod; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const installProfilesMod = require("./install-profiles.cjs"); | |
| const { readActiveProfile } = installProfilesMod; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const configMod = require("./config.cjs"); | |
| const { setConfigValues } = configMod; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const planningWorkspaceMod = require("./planning-workspace.cjs"); | |
| const { planningDir } = planningWorkspaceMod; | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const nodefs = require("fs"); | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const nodepath = require("path"); | |
| // βββ Implementation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| /** | |
| * Write-side capability state mutator. | |
| * | |
| * Applies desired capability state changes (enabled axis via surface, gates | |
| * axis via config) then re-resolves and returns the full capability state. | |
| * | |
| * Control flow: | |
| * 1. RESOLVE BEFORE STATE: call resolveCapabilityRuntimeState once to get the | |
| * canonical runtimeConfigDir and current capability state. | |
| * 2. VALIDATION PASS (no writes): validate each desired entry against the | |
| * registry and `before` state; collect errors and warnings. | |
| * 3. If errors β return early with before.capabilities (no writes performed). | |
| * 4. APPLY PASS: compute new surface state, writeSurface once if changed, | |
| * setConfigValues once for gate writes; materialize if opts provided. | |
| * 5. RE-RESOLVE: call resolveCapabilityRuntimeState again to get final state. | |
| * 6. POST CHECKS: enabled=true but not-surfaced (not-in-profile) error; | |
| * present-but-dead warning; append resolver warnings. | |
| * 7. Return { capabilities: after.capabilities, warnings, errors }. | |
| */ | |
| function setCapabilityState(cwd, runtimeConfigDir, desired, opts) { | |
| const warnings = []; | |
| const errors = []; | |
| // ββ Step 1: Resolve BEFORE state once ββββββββββββββββββββββββββββββββββββ | |
| const before = resolveCapabilityRuntimeState(cwd, runtimeConfigDir); | |
| const resolvedConfigDir = before.runtimeConfigDir; | |
| // ββ Load registry βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const registry = require('./capability-registry.cjs'); | |
| const capabilitiesMap = (registry['capabilities'] && typeof registry['capabilities'] === 'object' && !Array.isArray(registry['capabilities']) | |
| ? registry['capabilities'] | |
| : {}); | |
| // ββ Step 2: VALIDATION PASS (no writes) ββββββββββββββββββββββββββββββββββ | |
| // Accumulate all valid gate writes and surface deltas. | |
| // If ANY error is found, we will return early without writing anything. | |
| const pendingGateWrites = []; | |
| // surface-delta accumulators: ids to add to / remove from disabledClusters | |
| const idsToDisable = []; | |
| const idsToEnable = []; | |
| // Track which ids need surface loading (have skills + explicit enabled flag) | |
| let needsSurface = false; | |
| for (const entry of desired) { | |
| const { id, enabled, gates } = entry; | |
| // Validate capability id | |
| if (!Object.prototype.hasOwnProperty.call(capabilitiesMap, id)) { | |
| errors.push(`unknown capability: "${id}"`); | |
| continue; | |
| } | |
| const capObj = capabilitiesMap[id]; | |
| const skillsRaw = capObj['skills']; | |
| const skills = Array.isArray(skillsRaw) | |
| ? skillsRaw.filter((s) => typeof s === 'string') | |
| : []; | |
| const configDef = (capObj['config'] && typeof capObj['config'] === 'object' && !Array.isArray(capObj['config']) | |
| ? capObj['config'] | |
| : {}); | |
| // ββ Validate gate keys / values ββββββββββββββββββββββββββββββββββββββββββ | |
| if (gates !== undefined) { | |
| for (const [key, val] of Object.entries(gates)) { | |
| if (!Object.prototype.hasOwnProperty.call(configDef, key)) { | |
| errors.push(`unknown gate key "${key}" for capability "${id}"`); | |
| continue; | |
| } | |
| if (typeof val !== 'boolean') { | |
| errors.push(`gate value for "${key}" must be boolean, got ${typeof val}`); | |
| continue; | |
| } | |
| pendingGateWrites.push({ keyPath: key, value: val }); | |
| } | |
| } | |
| // ββ Validate enabled axis ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (enabled !== undefined) { | |
| if (skills.length === 0) { | |
| // Advisory only β no surface effect possible | |
| warnings.push(`capability "${id}" owns no skills; 'enabled' has no surface effect β use gates to toggle its hooks`); | |
| continue; | |
| } | |
| // Install-floor check: cannot enable a capability whose skills are not installed | |
| if (enabled === true) { | |
| const beforeEntry = before.capabilities.find((c) => c.id === id); | |
| if (beforeEntry && beforeEntry.installed === false) { | |
| errors.push(`cannot enable "${id}": its skills are not in the install profile`); | |
| continue; | |
| } | |
| } | |
| needsSurface = true; | |
| if (enabled === false) { | |
| idsToDisable.push(id); | |
| } | |
| else { | |
| idsToEnable.push(id); | |
| } | |
| } | |
| } | |
| // ββ Fix D: Pre-validate config.json parseability before any write ββββββββ | |
| // If there are pending gate writes, attempt to read and parse the target | |
| // config.json BEFORE writing anything. A malformed file would cause | |
| // setConfigValues to error() mid-operation leaving a partial write. | |
| if (pendingGateWrites.length > 0) { | |
| try { | |
| const configJsonPath = nodepath.join(planningDir(cwd), 'config.json'); | |
| if (nodefs.existsSync(configJsonPath)) { | |
| const raw = nodefs.readFileSync(configJsonPath, 'utf-8'); | |
| try { | |
| JSON.parse(raw); | |
| } | |
| catch (parseErr) { | |
| const msg = parseErr instanceof Error ? parseErr.message : String(parseErr); | |
| errors.push(`config.json is malformed: ${msg}`); | |
| } | |
| } | |
| } | |
| catch { | |
| // Cannot read the file path β not an error (e.g. planningDir env-var issue); let setConfigValues handle it | |
| } | |
| } | |
| // ββ Step 3: Early return on validation errors ββββββββββββββββββββββββββββ | |
| if (errors.length > 0) { | |
| return { | |
| capabilities: before.capabilities, | |
| warnings, | |
| errors, | |
| }; | |
| } | |
| // ββ Step 4: APPLY PASS ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // ββ Surface writes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (needsSurface && (idsToDisable.length > 0 || idsToEnable.length > 0)) { | |
| const existing = readSurface(resolvedConfigDir); | |
| let pendingSurface = existing ?? { | |
| baseProfile: readActiveProfile(resolvedConfigDir) ?? 'full', | |
| disabledClusters: [], | |
| explicitAdds: [], | |
| explicitRemoves: [], | |
| }; | |
| let surfaceChanged = false; | |
| for (const id of idsToDisable) { | |
| // Add id to disabledClusters (dedupe) | |
| if (!pendingSurface.disabledClusters.includes(id)) { | |
| pendingSurface = { | |
| ...pendingSurface, | |
| disabledClusters: [...pendingSurface.disabledClusters, id], | |
| }; | |
| surfaceChanged = true; | |
| } | |
| // Fix A: explicitAdds contains SKILL STEMS, not capability ids. | |
| // Remove the capability's skill stems from explicitAdds so that | |
| // resolveSurface does not re-add those skills after the cluster disable. | |
| const capObjForDisable = capabilitiesMap[id]; | |
| const skillsRawForDisable = capObjForDisable?.['skills']; | |
| const skillStemsForDisable = Array.isArray(skillsRawForDisable) | |
| ? skillsRawForDisable.filter((s) => typeof s === 'string') | |
| : []; | |
| const newExplicitAdds = pendingSurface.explicitAdds.filter((x) => !skillStemsForDisable.includes(x)); | |
| if (newExplicitAdds.length !== pendingSurface.explicitAdds.length) { | |
| pendingSurface = { ...pendingSurface, explicitAdds: newExplicitAdds }; | |
| surfaceChanged = true; | |
| } | |
| } | |
| for (const id of idsToEnable) { | |
| // Remove id from disabledClusters | |
| if (pendingSurface.disabledClusters.includes(id)) { | |
| pendingSurface = { | |
| ...pendingSurface, | |
| disabledClusters: pendingSurface.disabledClusters.filter((x) => x !== id), | |
| }; | |
| surfaceChanged = true; | |
| } | |
| // Fix A (enable branch): also remove the capability's skill stems from | |
| // explicitRemoves so that resolveSurface does not subtract those skills. | |
| // Do NOT add anything to explicitAdds β a cap that was only in explicitAdds | |
| // and was disabled is caught by the post-check below. | |
| const capObjForEnable = capabilitiesMap[id]; | |
| const skillsRawForEnable = capObjForEnable?.['skills']; | |
| const skillStemsForEnable = Array.isArray(skillsRawForEnable) | |
| ? skillsRawForEnable.filter((s) => typeof s === 'string') | |
| : []; | |
| const newExplicitRemoves = pendingSurface.explicitRemoves.filter((x) => !skillStemsForEnable.includes(x)); | |
| if (newExplicitRemoves.length !== pendingSurface.explicitRemoves.length) { | |
| pendingSurface = { ...pendingSurface, explicitRemoves: newExplicitRemoves }; | |
| surfaceChanged = true; | |
| } | |
| } | |
| if (surfaceChanged) { | |
| writeSurface(resolvedConfigDir, pendingSurface); | |
| } | |
| } | |
| // ββ Config writes (once, batched) βββββββββββββββββββββββββββββββββββββββββ | |
| if (pendingGateWrites.length > 0) { | |
| setConfigValues(cwd, pendingGateWrites); | |
| } | |
| // ββ Materialize (optional) ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (opts?.materialize) { | |
| const { runtime, scope } = opts.materialize; | |
| try { | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const runtimeArtifactLayout = require('./runtime-artifact-layout.cjs'); | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment | |
| const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope); | |
| const commandsGsdDir = _resolveCommandsGsdDir(); | |
| const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir); | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-argument | |
| applySurface(resolvedConfigDir, layout, manifest, undefined, registry); | |
| } | |
| catch (err) { | |
| const msg = err instanceof Error ? err.message : String(err); | |
| // Fix C: materialise was explicitly requested β a failure is an error (non-zero exit), | |
| // not merely advisory. | |
| errors.push(`materialize failed: ${msg}`); | |
| } | |
| } | |
| // ββ Step 5: RE-RESOLVE ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const after = resolveCapabilityRuntimeState(cwd, resolvedConfigDir); | |
| // ββ Step 6: POST CHECKS βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Check: desired enabled=true but not actually enabled after write | |
| // (catches the not-in-profile / not-surfaced silent no-op case). | |
| // The install-floor case (installed===false) was already caught in validation. | |
| for (const entry of desired) { | |
| if (entry.enabled === true) { | |
| const afterCap = after.capabilities.find((c) => c.id === entry.id); | |
| if (afterCap && afterCap.enabled !== true) { | |
| errors.push(`cannot enable "${entry.id}": not in the active surface/profile (widen the profile or use /gsd:surface enable)`); | |
| } | |
| } | |
| // Fix B: desired enabled=false β assert it is actually disabled after write. | |
| // Prevents "off means off" silent failures (e.g. explicitAdds containing the | |
| // cap's skill stems re-adds them after the cluster disable). | |
| if (entry.enabled === false) { | |
| const afterCap = after.capabilities.find((c) => c.id === entry.id); | |
| if (afterCap && afterCap.enabled !== false) { | |
| errors.push(`failed to disable "${entry.id}": still surfaced after write`); | |
| } | |
| } | |
| } | |
| // Check: present-but-dead β SCOPED to touched (desired) capabilities only. | |
| const desiredIds = new Set(desired.map((d) => d.id)); | |
| for (const cap of after.capabilities) { | |
| if (!desiredIds.has(cap.id)) | |
| continue; | |
| if (cap.enabled === true && | |
| cap.hooks.length > 0 && | |
| cap.hooks.every((h) => !h.configured)) { | |
| warnings.push(`capability "${cap.id}" is surfaced but every hook is gated off β did you mean enabled:false?`); | |
| } | |
| } | |
| // Append resolver's own warnings | |
| for (const w of after.warnings) { | |
| warnings.push(w); | |
| } | |
| return { | |
| capabilities: after.capabilities, | |
| warnings, | |
| errors, | |
| }; | |
| } | |
| /** | |
| * CLI command entry point for `gsd-tools capability set`. | |
| * | |
| * Builds one DesiredCapability from the provided options, calls setCapabilityState, | |
| * then prints the result. When raw=true emits JSON; else emits a human summary. | |
| * Warnings are always printed to stderr. | |
| */ | |
| function cmdCapabilitySet(cwd, runtimeConfigDir, capId, options, raw) { | |
| const desired = [ | |
| { | |
| id: capId, | |
| ...(options.enabled !== undefined ? { enabled: options.enabled } : {}), | |
| ...(options.gates ? { gates: options.gates } : {}), | |
| }, | |
| ]; | |
| const opts = options.runtime | |
| ? { materialize: { runtime: options.runtime, scope: options.scope ?? 'global' } } | |
| : undefined; | |
| const result = setCapabilityState(cwd, runtimeConfigDir, desired, opts); | |
| if (raw) { | |
| // Raw mode: emit JSON to stdout including errors; exit non-zero if errors present. | |
| // Do NOT print human stderr lines β raw consumers parse the JSON. | |
| coreOutput({ capabilities: result.capabilities, warnings: result.warnings, errors: result.errors }, true); | |
| if (result.errors.length > 0) { | |
| process.exit(1); | |
| } | |
| return; | |
| } | |
| // Human mode: print warnings and errors to stderr (non-fatally for warnings). | |
| for (const w of result.warnings) { | |
| process.stderr.write(`capability set: warning: ${w}\n`); | |
| } | |
| for (const e of result.errors) { | |
| process.stderr.write(`capability set: error: ${e}\n`); | |
| } | |
| // Exit non-zero if any errors (hard failures β requested action was not realized). | |
| if (result.errors.length > 0) { | |
| coreError(`capability set: ${String(result.errors.length)} error(s) β see above`); | |
| return; // unreachable β coreError calls process.exit(1) | |
| } | |
| // Human-readable summary: focus on the target capability | |
| const cap = result.capabilities.find((c) => c.id === capId); | |
| if (!cap) { | |
| const msg = `capability "${capId}" not found in registry`; | |
| coreOutput(msg, false, msg); | |
| return; | |
| } | |
| const activeHooks = cap.hooks.filter((h) => h.active).length; | |
| const summary = `capability ${capId}: enabled=${String(cap.enabled)}, surfaced=${String(cap.surfaced)}, installed=${String(cap.installed)}, activeHooks=${String(activeHooks)}/${String(cap.hooks.length)}`; | |
| coreOutput({ id: cap.id, enabled: cap.enabled, surfaced: cap.surfaced, installed: cap.installed, warnings: result.warnings.length > 0 ? result.warnings : undefined }, false, summary); | |
| } | |
| module.exports = { | |
| setCapabilityState, | |
| cmdCapabilitySet, | |
| }; | |