diff --git a/src/core/cli/core_commands.js b/src/core/cli/core_commands.js index f12014ec..db377fa0 100644 --- a/src/core/cli/core_commands.js +++ b/src/core/cli/core_commands.js @@ -46,6 +46,7 @@ import { runSkillsInstall, runUnignore, } from '../commands/clients.js' +import { runPolicyList, runPolicySet, runPolicyShow, runPolicyUnset } from '../commands/policy.js' /** * @import { CommandRegistration } from '../../../hypaware-plugin-kernel-types.js' @@ -270,6 +271,46 @@ function buildCoreCommands(registry) { ].join('\n'), run: runUnignore, }, + makeGroupCommand({ + registry, + name: 'policy', + summary: 'Mark a folder machine-local usage class (sync, local-only, ignore)', + help: [ + 'The class-neutral successor to the hyp ignore --sync/--local-only/--private', + 'flags: writes to the same machine-local, class-per-entry store (never a', + '.hypignore dotfile). set/show/unset act on one path; list enumerates every', + 'machine-local entry on this machine.', + ].join('\n'), + }), + { + name: 'policy set', + summary: 'Mark a folder machine-local sync, local-only, or ignore', + usage: 'hyp policy set sync|local-only|ignore', + run: runPolicySet, + }, + { + name: 'policy show', + summary: 'Report the usage class governing a folder and its source', + usage: 'hyp policy show [path] [--json]', + run: runPolicyShow, + }, + { + name: 'policy unset', + summary: 'Remove machine-local markings governing a folder (optionally scoped to one class)', + usage: 'hyp policy unset [sync|local-only|ignore]', + help: [ + 'With no trailing class token, removes every machine-local entry governing', + ' (class-neutral: back to the implicit default). With a trailing', + 'sync/local-only/ignore token, removes only entries of that class.', + ].join('\n'), + run: runPolicyUnset, + }, + { + name: 'policy list', + summary: 'Enumerate machine-local usage-class entries', + usage: 'hyp policy list [--json]', + run: runPolicyList, + }, { name: 'purge', summary: 'Delete already-cached rows from the local cache (destructive)', diff --git a/src/core/commands/clients.js b/src/core/commands/clients.js index 1ced746d..893508fb 100644 --- a/src/core/commands/clients.js +++ b/src/core/commands/clients.js @@ -640,10 +640,27 @@ export async function runIgnore(argv, ctx) { // dispatch writes/removes/checks the tree the caller actually pointed at. const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') if (parsed.check) return runIgnoreCheck({ targetDir: base, ctx, json: parsed.json }) - if (parsed.private) return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'ignore' }) + if (parsed.private) + return runMarkMachineLocal({ + targetDir: repoRootDefaultTarget(base, parsed.path), + ctx, + targetClass: 'ignore', + component: 'cmd-ignore', + }) if (parsed.localOnly) - return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'local-only' }) - if (parsed.sync) return runMarkMachineLocal({ targetDir: repoRootDefaultTarget(base, parsed.path), ctx, targetClass: 'full' }) + return runMarkMachineLocal({ + targetDir: repoRootDefaultTarget(base, parsed.path), + ctx, + targetClass: 'local-only', + component: 'cmd-ignore', + }) + if (parsed.sync) + return runMarkMachineLocal({ + targetDir: repoRootDefaultTarget(base, parsed.path), + ctx, + targetClass: 'full', + component: 'cmd-ignore', + }) // Idempotent (R5): a fresh resolver reflects disk. Any governing ancestor // `.hypignore` already ignores `base` (V1 has no un-ignore directive, any @@ -724,10 +741,11 @@ function repoRootDefaultTarget(base, explicitPath) { * an unlisted directory is not "already answered", LLP 0103). * * @ref LLP 0103#cli [implements]: the shared machine-local marking verb behind `--private` / `--local-only` / `--sync` - * @param {{ targetDir: string, ctx: CommandRunContext, targetClass: UsageClass }} args + * @ref LLP 0111#surface [implements]: also the shared implementation behind `policy set`; only the `component` attribute names the dispatching verb + * @param {{ targetDir: string, ctx: CommandRunContext, targetClass: UsageClass, component: string }} args * @returns {Promise} */ -async function runMarkMachineLocal({ targetDir, ctx, targetClass }) { +export async function runMarkMachineLocal({ targetDir, ctx, targetClass, component }) { const resolvedTarget = path.resolve(targetDir) const stateDir = readObservabilityEnv(ctx.env).stateDir const listPath = localOnlyListPath(stateDir) @@ -746,7 +764,7 @@ async function runMarkMachineLocal({ targetDir, ctx, targetClass }) { const withoutTarget = entries.filter((entry) => entry.dir !== resolvedTarget) await writeLocalOnlyEntries({ stateDir, entries: [...withoutTarget, { dir: resolvedTarget, class: targetClass }] }) getLogger('usage-policy').info('usage_policy.mark', { - [Attr.COMPONENT]: 'cmd-ignore', + [Attr.COMPONENT]: component, [Attr.OPERATION]: 'usage_policy.mark', class: targetClass, status: 'ok', @@ -790,9 +808,10 @@ export async function runUnignore(argv, ctx) { // sibling verbs above), not the Node process cwd, so injected/remote/test // dispatch writes/removes/checks the tree the caller actually pointed at. const base = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') - if (parsed.private) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'ignore' }) - if (parsed.localOnly) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'local-only' }) - if (parsed.sync) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'full' }) + if (parsed.private) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'ignore', component: 'cmd-unignore' }) + if (parsed.localOnly) + return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'local-only', component: 'cmd-unignore' }) + if (parsed.sync) return runUnmarkMachineLocal({ targetDir: base, ctx, targetClass: 'full', component: 'cmd-unignore' }) const { governedBy } = createUsagePolicyResolver().resolve(base) if (!governedBy) { @@ -819,29 +838,40 @@ export async function runUnignore(argv, ctx) { * `hyp unignore --private [path]` / `hyp unignore --local-only [path]` / * `hyp unignore --sync [path]` * - * Removes every machine-local entry of `targetClass` that governs - * `targetDir` — equal to it, or an ancestor of it (the same segment-aware - * rule the shared resolver applies, reused here via - * {@link isEqualOrDescendant} rather than re-derived, R8) — mirroring - * dotfile `unignore`'s "remove the governing thing" semantics. Entries of a - * different class are left alone (LLP 0104 boundary: unmarking is - * class-scoped and non-destructive of cached rows either way). Idempotent: - * no governing entry of that class is a no-op success. Verb-agnostic: the - * caller resolves `targetDir` (today the flag forms' cwd-relative `base`, - * with no repo-root default), so this implementation is shared by the - * `--private`/`--local-only`/`--sync` flag branches and, in a future task, - * the `policy unset` runner. + * Removes every machine-local entry that governs `targetDir`, equal to it, + * or an ancestor of it (the same segment-aware rule the shared resolver + * applies, reused here via {@link isEqualOrDescendant} rather than + * re-derived, R8), mirroring dotfile `unignore`'s "remove the governing + * thing" semantics. When `targetClass` is given, removal is scoped to that + * one class and entries of a different class are left alone (LLP 0104 + * boundary: unmarking is class-scoped and non-destructive of cached rows + * either way): this is what the `--private`/`--local-only`/`--sync` + * `hyp unignore` flag branches pass. When `targetClass` is omitted, removal + * is class-neutral: every machine-local entry governing `targetDir`, of any + * class, is removed - `policy unset `'s "back to the implicit + * default" default (LLP 0111 #unset). Idempotent either way: no governing + * entry (of that class, or of any class) is a no-op success. Verb-agnostic: + * the caller resolves `targetDir` (today the flag forms' cwd-relative + * `base`, with no repo-root default) and supplies `component` to name the + * dispatching verb in the structured log event. * * @ref LLP 0103#cli [implements]: symmetric class-scoped removal for `--private` / `--local-only` / `--sync` - * @param {{ targetDir: string, ctx: CommandRunContext, targetClass: UsageClass }} args + * @ref LLP 0111#unset [implements]: the class-neutral `targetClass === undefined` branch backing `policy unset ` + * @param {{ targetDir: string, ctx: CommandRunContext, targetClass?: UsageClass, component: string }} args * @returns {Promise} */ -async function runUnmarkMachineLocal({ targetDir, ctx, targetClass }) { +export async function runUnmarkMachineLocal({ targetDir, ctx, targetClass, component }) { const stateDir = readObservabilityEnv(ctx.env).stateDir const entries = await readLocalOnlyEntries({ stateDir }) - const governing = entries.filter((entry) => entry.class === targetClass && isEqualOrDescendant(targetDir, entry.dir)) + const governing = entries.filter( + (entry) => (targetClass === undefined || entry.class === targetClass) && isEqualOrDescendant(targetDir, entry.dir) + ) if (governing.length === 0) { - ctx.stdout.write(`not ${targetClass} (no machine-local ${targetClass} entry governs ${targetDir})\n`) + if (targetClass === undefined) { + ctx.stdout.write(`not governed (no machine-local entry governs ${targetDir})\n`) + } else { + ctx.stdout.write(`not ${targetClass} (no machine-local ${targetClass} entry governs ${targetDir})\n`) + } return 0 } @@ -849,15 +879,20 @@ async function runUnmarkMachineLocal({ targetDir, ctx, targetClass }) { const remaining = entries.filter((entry) => !governingDirs.has(entry.dir)) await writeLocalOnlyEntries({ stateDir, entries: remaining }) getLogger('usage-policy').info('usage_policy.unmark', { - [Attr.COMPONENT]: 'cmd-unignore', + [Attr.COMPONENT]: component, [Attr.OPERATION]: 'usage_policy.unmark', - class: targetClass, + class: targetClass ?? 'any', status: 'ok', }) - const removedDirs = governing.map((entry) => entry.dir) - ctx.stdout.write( - `removed ${governing.length} ${targetClass} entr${governing.length === 1 ? 'y' : 'ies'}: ${removedDirs.join(', ')}\n` - ) + const entrySuffix = governing.length === 1 ? 'y' : 'ies' + if (targetClass === undefined) { + // Class-neutral: name each removed entry's own class since they can differ. + const removedDescr = governing.map((entry) => `${entry.dir} (${entry.class})`).join(', ') + ctx.stdout.write(`removed ${governing.length} entr${entrySuffix}: ${removedDescr}\n`) + } else { + const removedDirs = governing.map((entry) => entry.dir) + ctx.stdout.write(`removed ${governing.length} ${targetClass} entr${entrySuffix}: ${removedDirs.join(', ')}\n`) + } return 0 } @@ -884,10 +919,11 @@ async function runUnmarkMachineLocal({ targetDir, ctx, targetClass }) { * * @ref LLP 0049#prospective-only [implements]: `--check` reports the residual already-cached row count; it never deletes * @ref LLP 0103#cli [implements]: `--check` names which source governs (dotfile vs machine-local entry) and the entry's class + * @ref LLP 0111#show [implements]: also the shared implementation behind `policy show`; `--json` stays byte-compatible with the `--check --json` field set * @param {{ targetDir: string, ctx: CommandRunContext, json: boolean }} args * @returns {Promise} */ -async function runIgnoreCheck({ targetDir, ctx, json }) { +export async function runIgnoreCheck({ targetDir, ctx, json }) { const stateDir = readObservabilityEnv(ctx.env).stateDir const listPath = localOnlyListPath(stateDir) const result = createUsagePolicyResolver({ localOnlyListPath: listPath }).resolve(targetDir) diff --git a/src/core/commands/policy.js b/src/core/commands/policy.js new file mode 100644 index 00000000..d251521d --- /dev/null +++ b/src/core/commands/policy.js @@ -0,0 +1,278 @@ +// @ts-check + +import path from 'node:path' +import process from 'node:process' + +import { parseCommandArgv } from '../cli/verb_codec.js' +import { readObservabilityEnv } from '../observability/env.js' +import { localOnlyListPath, readLocalOnlyEntries } from '../usage-policy/index.js' +import { runIgnoreCheck, runMarkMachineLocal, runUnmarkMachineLocal } from './clients.js' + +/** + * @import { CommandRunContext } from '../../../hypaware-plugin-kernel-types.js' + * @import { UsageClass } from '../../../src/core/usage-policy/types.js' + */ + +/** + * The `hyp policy` command group (LLP 0110, LLP 0111): a class-neutral verb + * over the machine-local usage-class store that replaces the + * `hyp ignore --sync`/`--local-only`/`--private` misnomer. `set` / `show` / + * `unset` / `list` are thin runners over the marking internals hoisted in + * `src/core/commands/clients.js`; the `hyp ignore`/`hyp unignore` flag forms + * keep working as delegating compatibility aliases (see + * {@link runMarkMachineLocal}, {@link runUnmarkMachineLocal}, + * {@link runIgnoreCheck}). The store format, the shared resolver, and the + * three-class lattice are untouched (LLP 0103 #cli). + * + * @ref LLP 0110 [implements]: the class-neutral `policy` verb surface that retires the `hyp ignore --sync` misnomer + * @ref LLP 0111#surface [implements]: `policy set` / `show` / `unset` / `list`, registered as a `makeGroupCommand` group + * @ref LLP 0103#cli [constrained-by]: the store, resolver, and class lattice are unchanged; only the verb spelling is new + */ + +/** + * The user-facing class vocabulary the classification hook and the + * hypaware-privacy skill already teach. Mapped onto the store's class + * lattice at the CLI edge only (LLP 0111 #tokens): `sync` is the "asked; + * syncs" marker (stored as `full`); `local-only` and `ignore` map onto + * themselves. `policy show --json` / `policy list --json` keep emitting the + * resolver vocabulary (`full`/`local-only`/`ignore`) unchanged, so existing + * consumers of the `--check --json` shape see identical fields. + * + * @ref LLP 0111#tokens [implements]: the sync -> full token mapping lives at the CLI edge; the store keeps speaking `full` + */ +const CLASS_TOKENS = /** @type {const} */ (['sync', 'local-only', 'ignore']) + +/** @type {Record<(typeof CLASS_TOKENS)[number], UsageClass>} */ +const TOKEN_TO_CLASS = { sync: 'full', 'local-only': 'local-only', ignore: 'ignore' } + +/** @type {Record} */ +const CLASS_TO_TOKEN_GLOSS = { full: 'sync', 'local-only': 'local-only', ignore: 'ignore' } + +const POLICY_SET_USAGE = 'hyp policy set sync|local-only|ignore' +const POLICY_SHOW_USAGE = 'hyp policy show [path] [--json]' +const POLICY_UNSET_USAGE = 'hyp policy unset [sync|local-only|ignore]' +const POLICY_LIST_USAGE = 'hyp policy list [--json]' + +/** + * @param {string[]} argv + * @returns {{ path?: string, token?: string, error?: string }} + */ +function parsePolicySetArgs(argv) { + const parsed = parseCommandArgv(argv, { + type: 'object', + properties: { + path: { type: 'string' }, + class: { type: 'string', enum: [...CLASS_TOKENS] }, + }, + positional: ['path', 'class'], + required: ['path', 'class'], + }) + if ('help' in parsed) return { error: `usage: ${POLICY_SET_USAGE}` } + if (!parsed.ok) return { error: parsed.error } + const p = /** @type {{ path: string, class: string }} */ (parsed.params) + return { path: p.path, token: p.class } +} + +/** + * @param {string[]} argv + * @returns {{ path?: string, json: boolean, error?: string }} + */ +function parsePolicyShowArgs(argv) { + const empty = { json: false } + const parsed = parseCommandArgv(argv, { + type: 'object', + properties: { + path: { type: 'string' }, + json: { type: 'boolean', default: false }, + }, + positional: ['path'], + }) + if ('help' in parsed) return { ...empty, error: `usage: ${POLICY_SHOW_USAGE}` } + if (!parsed.ok) return { ...empty, error: parsed.error } + const p = /** @type {{ path?: string, json: boolean }} */ (parsed.params) + return { path: p.path, json: p.json } +} + +/** + * @param {string[]} argv + * @returns {{ path?: string, token?: string, error?: string }} + */ +function parsePolicyUnsetArgs(argv) { + const parsed = parseCommandArgv(argv, { + type: 'object', + properties: { + path: { type: 'string' }, + class: { type: 'string', enum: [...CLASS_TOKENS] }, + }, + positional: ['path', 'class'], + required: ['path'], + }) + if ('help' in parsed) return { error: `usage: ${POLICY_UNSET_USAGE}` } + if (!parsed.ok) return { error: parsed.error } + const p = /** @type {{ path: string, class?: string }} */ (parsed.params) + return { path: p.path, token: p.class } +} + +/** + * @param {string[]} argv + * @returns {{ json: boolean, error?: string }} + */ +function parsePolicyListArgs(argv) { + const empty = { json: false } + const parsed = parseCommandArgv(argv, { + type: 'object', + properties: { + json: { type: 'boolean', default: false }, + }, + }) + if ('help' in parsed) return { ...empty, error: `usage: ${POLICY_LIST_USAGE}` } + if (!parsed.ok) return { ...empty, error: parsed.error } + const p = /** @type {{ json: boolean }} */ (parsed.params) + return { json: p.json } +} + +/** + * `hyp policy set sync|local-only|ignore` + * + * Writes a machine-local usage-class marking for `` in the + * class-per-entry store (LLP 0103), delegating to + * {@link runMarkMachineLocal}, the internal both this verb and the + * `hyp ignore --sync`/`--local-only`/`--private` compatibility aliases call. + * `` is required (the bare grammar makes it necessary: `hyp policy set + * sync` would be ambiguous between a path and a class token) and resolved + * against the command-context cwd, matching the sibling verbs; the resolved + * directory is marked exactly where it points, with no repo-root default + * (LLP 0111 #set) - an explicit path already says which directory is meant. + * `set ignore` writes a machine-local `ignore` entry; it never writes + * a `.hypignore` dotfile (that stays bare `hyp ignore`'s job alone). An + * unknown class token is a usage error (exit 2) naming the three valid + * tokens (`sync`, `local-only`, `ignore`). + * + * @ref LLP 0110 [implements]: the class-neutral `policy set` that replaces the `hyp ignore --sync` misnomer for consent-adjacent marking + * @ref LLP 0111#set [implements]: required path, sync -> full token mapping, delegates to the hoisted marking internal + * @ref LLP 0103#cli [constrained-by]: the store, resolver, and class lattice are unchanged; only the verb spelling is new + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runPolicySet(argv, ctx) { + const parsed = parsePolicySetArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + const targetDir = path.resolve(ctx.cwd ?? process.cwd(), /** @type {string} */ (parsed.path)) + const targetClass = TOKEN_TO_CLASS[/** @type {(typeof CLASS_TOKENS)[number]} */ (parsed.token)] + return runMarkMachineLocal({ targetDir, ctx, targetClass, component: 'cmd-policy-set' }) +} + +/** + * `hyp policy show [path] [--json]` + * + * The class-neutral successor to `hyp ignore --check`: resolves `[path]` + * (default cwd, preserving `--check`'s ergonomics) and reports the resolved + * class, the governing source (`dotfile`/`machine-local`/`none`), the + * governing file, and the residual already-cached row count with the + * `hyp purge` hint. Prospective-only, never destructive. `--json` emits the + * exact field set `hyp ignore --check --json` emits today (byte-compatible), + * since {@link runIgnoreCheck} is the shared implementation both spellings + * call. + * + * @ref LLP 0110 [implements]: the class-neutral `policy show`, the `hyp ignore --check` successor + * @ref LLP 0111#show [implements]: `--json` stays byte-compatible with today's `--check --json` field set + * @ref LLP 0103#cli [constrained-by]: names the governing source (dotfile vs machine-local) and class; store/resolver unchanged + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runPolicyShow(argv, ctx) { + const parsed = parsePolicyShowArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + const targetDir = path.resolve(ctx.cwd ?? process.cwd(), parsed.path ?? '.') + return runIgnoreCheck({ targetDir, ctx, json: parsed.json }) +} + +/** + * `hyp policy unset [sync|local-only|ignore]` + * + * Removes machine-local markings governing `` (equal to it, or an + * ancestor of it). By default (no trailing class token) it is class-neutral: + * every machine-local entry governing the target is removed, "back to the + * implicit default" (LLP 0111 #unset), matching the store's one-entry-per-dir + * shape. An optional trailing class token scopes removal to that class only + * - the scoped form the `hyp unignore --sync`/`--local-only`/`--private` + * aliases delegate to. Both forms delegate to {@link runUnmarkMachineLocal}. + * `unset` never touches `.hypignore` dotfiles and never touches cached rows + * (LLP 0104 boundary). Idempotent: nothing governing (of the given class, or + * of any class) is a no-op success. + * + * @ref LLP 0110 [implements]: the class-neutral `policy unset`, replacing per-class `hyp unignore` flags as the primary spelling + * @ref LLP 0111#unset [implements]: class-neutral by default, an optional trailing class token scopes it + * @ref LLP 0103#cli [constrained-by]: reuses the shared `isEqualOrDescendant` ancestor predicate; store/resolver unchanged + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runPolicyUnset(argv, ctx) { + const parsed = parsePolicyUnsetArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + const targetDir = path.resolve(ctx.cwd ?? process.cwd(), /** @type {string} */ (parsed.path)) + const targetClass = parsed.token + ? TOKEN_TO_CLASS[/** @type {(typeof CLASS_TOKENS)[number]} */ (parsed.token)] + : undefined + return runUnmarkMachineLocal({ targetDir, ctx, targetClass, component: 'cmd-policy-unset' }) +} + +/** + * `hyp policy list [--json]` + * + * Enumerates the machine-local class-per-entry store (LLP 0103): one line + * per entry with its `dir` and class (`full` renders with a `sync` gloss for + * the human reader), plus the store path; `--json` emits + * `{ entries: [{ dir, class }], path }`. This is the store's first + * enumeration surface (LLP 0111 #list): `policy show` answers "what governs + * this path", `list` answers "what have I marked on this machine". It + * deliberately lists only the machine-local store - `.hypignore` dotfiles + * are discovered per-path by the ancestor walk and cannot be enumerated + * without a filesystem crawl, and `show` already names them when they + * govern. An empty store lists zero entries successfully. + * + * @ref LLP 0110 [implements]: names the machine-local store's enumeration surface with the class-neutral verb + * @ref LLP 0111#list [implements]: the store's first enumeration surface; `--json` emits `{ entries, path }` + * @ref LLP 0103#cli [constrained-by]: enumerates the version-2 class-per-entry store as-is; no format change + * @param {string[]} argv + * @param {CommandRunContext} ctx + * @returns {Promise} + */ +export async function runPolicyList(argv, ctx) { + const parsed = parsePolicyListArgs(argv) + if (parsed.error) { + ctx.stderr.write(`error: ${parsed.error}\n`) + return 2 + } + const stateDir = readObservabilityEnv(ctx.env).stateDir + const listPath = localOnlyListPath(stateDir) + const entries = await readLocalOnlyEntries({ stateDir }) + + if (parsed.json) { + ctx.stdout.write(JSON.stringify({ entries, path: listPath }) + '\n') + return 0 + } + + if (entries.length === 0) { + ctx.stdout.write(`no machine-local entries (${listPath})\n`) + return 0 + } + for (const entry of entries) { + const gloss = entry.class === 'full' ? ` (${CLASS_TO_TOKEN_GLOSS.full})` : '' + ctx.stdout.write(`${entry.dir}: ${entry.class}${gloss}\n`) + } + ctx.stdout.write(`(${listPath})\n`) + return 0 +} diff --git a/test/core/policy-command.test.js b/test/core/policy-command.test.js new file mode 100644 index 00000000..93815e8a --- /dev/null +++ b/test/core/policy-command.test.js @@ -0,0 +1,426 @@ +// @ts-check + +import assert from 'node:assert/strict' +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { registerCoreCommands } from '../../src/core/cli/core_commands.js' +import { createCommandRegistry } from '../../src/core/registry/commands.js' +import { + localOnlyListPath, + readLocalOnlyEntries, + writeLocalOnlyEntries, +} from '../../src/core/usage-policy/local_only.js' + +/** + * @import { CommandRegistration, CommandRunContext } from '../../hypaware-plugin-kernel-types.js' + */ + +// `hyp policy set/show/unset/list` (LLP 0110, LLP 0111, task T2): the +// class-neutral verb group over the machine-local class-per-entry store, +// mirroring test/core/ignore-private-sync-command.test.js for the new +// spellings, plus list and class-neutral unset coverage that has no flag-form +// counterpart. These verbs never touch the repo tree - the write target is a +// `HYP_HOME`-state JSON file - so every test roots the repo tree and the +// `HYP_HOME` state tree at two distinct temp directories. + +/** @returns {{ write(chunk: unknown): boolean, text(): string }} */ +function makeBuf() { + let value = '' + return { + write(chunk) { + value += String(chunk) + return true + }, + text() { + return value + }, + } +} + +/** @param {string} name */ +function getCommand(name) { + const registry = createCommandRegistry() + registerCoreCommands(registry) + const command = registry.get(name) + assert.ok(command, `${name} is registered`) + return /** @type {CommandRegistration} */ (command) +} + +/** + * @param {string} name + * @param {string[]} argv + * @param {{ cwd: string, hypHome: string }} opts + */ +async function run(name, argv, opts) { + const stdout = makeBuf() + const stderr = makeBuf() + const ctx = /** @type {any} */ ({ + stdout, + stderr, + cwd: opts.cwd, + env: { HYP_HOME: opts.hypHome }, + config: { version: 2 }, + query: { getDataset: () => undefined, listDatasets: () => [] }, + storage: { cacheRoot: path.join(opts.cwd, '.cache'), pendingInfo: async () => ({ pending: false }) }, + }) + const code = await getCommand(name).run(argv, /** @type {CommandRunContext} */ (ctx)) + return { code, stdout: stdout.text(), stderr: stderr.text() } +} + +/** @param {(dirs: { root: string, hypHome: string }) => Promise | void} fn */ +async function withSandbox(fn) { + const root = mkdtempSync(path.join(tmpdir(), 'hyp-policy-repo-')) + const hypHome = mkdtempSync(path.join(tmpdir(), 'hyp-policy-home-')) + try { + await fn({ root, hypHome }) + } finally { + rmSync(root, { recursive: true, force: true }) + rmSync(hypHome, { recursive: true, force: true }) + } +} + +/** @param {string} hypHome */ +function stateDirOf(hypHome) { + return path.join(hypHome, 'hypaware') +} + +/* --------------------------------- policy set -------------------------------- */ + +test('hyp policy set ignore marks the path ignore in the machine-local store, never touching the repo', async () => { + await withSandbox(async ({ root, hypHome }) => { + mkdirSync(path.join(root, '.git')) + + const res = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /marked .* as ignore/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'ignore' }]) + assert.ok(!existsSync(path.join(root, '.hypignore')), 'never writes a .hypignore dotfile') + }) +}) + +test('hyp policy set ignore is idempotent: marking twice is a no-op success', async () => { + await withSandbox(async ({ root, hypHome }) => { + const first = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(first.code, 0) + + const second = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(second.code, 0) + assert.match(second.stdout, /already ignore/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'ignore' }], 'no duplicate entry') + }) +}) + +test('hyp policy set ignore upgrades an existing local-only entry to ignore', async () => { + await withSandbox(async ({ root, hypHome }) => { + await writeLocalOnlyEntries({ stateDir: stateDirOf(hypHome), entries: [{ dir: root, class: 'local-only' }] }) + + const res = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /marked .* as ignore/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'ignore' }]) + }) +}) + +test('hyp policy set ignore on a path already governed by a stricter .hypignore is a no-op naming the dotfile', async () => { + await withSandbox(async ({ root, hypHome }) => { + writeFileSync(path.join(root, '.hypignore'), 'ignore\n') + + const res = await run('policy set', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /already ignore/) + assert.match(res.stdout, new RegExp(path.join(root, '.hypignore').replace(/[.\\]/g, '\\$&'))) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [], 'the dotfile already suppresses recording; nothing is added to the store') + }) +}) + +test('hyp policy set sync writes an explicit full entry (the sync -> full token mapping)', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy set', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /marked .* as full/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'full' }], 'the store keeps speaking `full`; only the CLI token is `sync`') + }) +}) + +test('hyp policy set sync is idempotent against an existing explicit full entry, but not against the mere implicit default', async () => { + await withSandbox(async ({ root, hypHome }) => { + const first = await run('policy set', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(first.code, 0) + assert.match(first.stdout, /marked .* as full/, 'no entry existed yet, so this must write, not no-op') + + const second = await run('policy set', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(second.code, 0) + assert.match(second.stdout, /already full/, 'now an explicit entry exists, so this is idempotent') + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'full' }], 'no duplicate entry') + }) +}) + +test('hyp policy set local-only marks the path local-only', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy set', [root, 'local-only'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /marked .* as local-only/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: root, class: 'local-only' }]) + }) +}) + +test('hyp policy set rejects an unknown class token with a usage error naming the three valid tokens', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy set', [root, 'private'], { cwd: root, hypHome }) + assert.equal(res.code, 2) + assert.match(res.stderr, /sync/) + assert.match(res.stderr, /local-only/) + assert.match(res.stderr, /ignore/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [], 'nothing is written on a rejected token') + }) +}) + +test('hyp policy set requires a path (bare class token alone is ambiguous, so it is rejected)', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy set', ['sync'], { cwd: root, hypHome }) + assert.equal(res.code, 2) + }) +}) + +/* -------------------------------- policy show --------------------------------- */ + +test('hyp policy show [path] --json is byte-compatible with hyp ignore --check --json for a machine-local mark', async () => { + await withSandbox(async ({ root, hypHome }) => { + await writeLocalOnlyEntries({ stateDir: stateDirOf(hypHome), entries: [{ dir: root, class: 'ignore' }] }) + + const legacy = await run('ignore', ['--check', '--json'], { cwd: root, hypHome }) + const next = await run('policy show', [root, '--json'], { cwd: root, hypHome }) + assert.equal(next.code, 0) + assert.equal(legacy.code, 0) + assert.deepEqual(JSON.parse(next.stdout), JSON.parse(legacy.stdout)) + + const parsed = JSON.parse(next.stdout) + assert.equal(parsed.class, 'ignore') + assert.equal(parsed.source, 'machine-local') + assert.equal(parsed.governedBy, localOnlyListPath(stateDirOf(hypHome))) + }) +}) + +test('hyp policy show defaults to cwd and names the dotfile source when a .hypignore governs', async () => { + await withSandbox(async ({ root, hypHome }) => { + writeFileSync(path.join(root, '.hypignore'), 'ignore\n') + + const res = await run('policy show', ['--json'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.source, 'dotfile') + assert.equal(parsed.class, 'ignore') + }) +}) + +test('hyp policy show names no source when nothing governs (the implicit default)', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy show', [root, '--json'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.class, 'full') + assert.equal(parsed.source, 'none') + }) +}) + +/* -------------------------------- policy unset --------------------------------- */ + +test('hyp policy unset ignore (scoped) removes an ignore entry and is idempotent, leaving other classes untouched', async () => { + await withSandbox(async ({ root, hypHome }) => { + const other = path.join(root, 'other') + mkdirSync(other, { recursive: true }) + await writeLocalOnlyEntries({ + stateDir: stateDirOf(hypHome), + entries: [ + { dir: root, class: 'ignore' }, + { dir: other, class: 'local-only' }, + ], + }) + + const first = await run('policy unset', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(first.code, 0) + assert.match(first.stdout, /removed 1 ignore entry/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: other, class: 'local-only' }], 'the unrelated local-only entry survives') + + const second = await run('policy unset', [root, 'ignore'], { cwd: root, hypHome }) + assert.equal(second.code, 0, 'unsetting an already-clean path still succeeds') + assert.match(second.stdout, /not ignore/) + }) +}) + +test('hyp policy unset sync (scoped) removes an explicit full entry and is idempotent', async () => { + await withSandbox(async ({ root, hypHome }) => { + await writeLocalOnlyEntries({ stateDir: stateDirOf(hypHome), entries: [{ dir: root, class: 'full' }] }) + + const first = await run('policy unset', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(first.code, 0) + assert.match(first.stdout, /removed 1 full entry/) + assert.deepEqual(await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }), []) + + const second = await run('policy unset', [root, 'sync'], { cwd: root, hypHome }) + assert.equal(second.code, 0) + assert.match(second.stdout, /not full/) + }) +}) + +test('hyp policy unset (no trailing class token) is class-neutral: removes every machine-local entry governing the target', async () => { + await withSandbox(async ({ root, hypHome }) => { + const sub = path.join(root, 'a', 'b') + mkdirSync(sub, { recursive: true }) + const unrelated = path.join(root, 'sibling') + mkdirSync(unrelated) + // Two entries of different classes both govern `sub` (via ancestry); + // an unrelated sibling entry must survive untouched. + await writeLocalOnlyEntries({ + stateDir: stateDirOf(hypHome), + entries: [ + { dir: root, class: 'local-only' }, + { dir: unrelated, class: 'ignore' }, + ], + }) + + const res = await run('policy unset', [sub], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /removed 1 entry/) + assert.match(res.stdout, /local-only/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [{ dir: unrelated, class: 'ignore' }], 'only the entry governing sub is removed') + }) +}) + +test('hyp policy unset (no trailing class token) removes multiple entries of different classes governing the same target', async () => { + await withSandbox(async ({ root, hypHome }) => { + // Not physically realizable via the store's one-entry-per-dir shape at a + // single dir, but an ancestor entry plus an exact-dir entry can both + // govern the same target simultaneously. + const sub = path.join(root, 'nested') + mkdirSync(sub, { recursive: true }) + await writeLocalOnlyEntries({ + stateDir: stateDirOf(hypHome), + entries: [ + { dir: root, class: 'local-only' }, + { dir: sub, class: 'ignore' }, + ], + }) + + const res = await run('policy unset', [sub], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /removed 2 entries/) + + const entries = await readLocalOnlyEntries({ stateDir: stateDirOf(hypHome) }) + assert.deepEqual(entries, [], 'both the exact-dir entry and the governing ancestor entry are removed') + }) +}) + +test('hyp policy unset (no trailing class token) on an already-clean path is a class-neutral no-op success', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy unset', [root], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /not governed/) + }) +}) + +test('hyp policy unset rejects an unknown trailing class token with a usage error naming the three valid tokens', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy unset', [root, 'private'], { cwd: root, hypHome }) + assert.equal(res.code, 2) + assert.match(res.stderr, /sync/) + assert.match(res.stderr, /local-only/) + assert.match(res.stderr, /ignore/) + }) +}) + +test('hyp policy unset requires a path', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy unset', [], { cwd: root, hypHome }) + assert.equal(res.code, 2) + }) +}) + +/* --------------------------------- policy list --------------------------------- */ + +test('hyp policy list --json enumerates every machine-local entry with the store path', async () => { + await withSandbox(async ({ root, hypHome }) => { + const other = path.join(root, 'other') + mkdirSync(other, { recursive: true }) + await writeLocalOnlyEntries({ + stateDir: stateDirOf(hypHome), + entries: [ + { dir: root, class: 'full' }, + { dir: other, class: 'ignore' }, + ], + }) + + const res = await run('policy list', ['--json'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.equal(parsed.path, localOnlyListPath(stateDirOf(hypHome))) + assert.deepEqual(parsed.entries, [ + { dir: root, class: 'full' }, + { dir: other, class: 'ignore' }, + ]) + }) +}) + +test('hyp policy list --json on an empty store lists zero entries successfully', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy list', ['--json'], { cwd: root, hypHome }) + assert.equal(res.code, 0) + const parsed = JSON.parse(res.stdout) + assert.deepEqual(parsed.entries, []) + assert.equal(parsed.path, localOnlyListPath(stateDirOf(hypHome))) + }) +}) + +test('hyp policy list (human) renders a full entry with a sync gloss', async () => { + await withSandbox(async ({ root, hypHome }) => { + await writeLocalOnlyEntries({ stateDir: stateDirOf(hypHome), entries: [{ dir: root, class: 'full' }] }) + + const res = await run('policy list', [], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /full \(sync\)/) + }) +}) + +test('hyp policy list (human) on an empty store reports no entries without error', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy list', [], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /no machine-local entries/) + }) +}) + +/* ------------------------------ group registration ------------------------------ */ + +test('hyp policy (bare) renders group help listing set/show/unset/list', async () => { + await withSandbox(async ({ root, hypHome }) => { + const res = await run('policy', [], { cwd: root, hypHome }) + assert.equal(res.code, 0) + assert.match(res.stdout, /set/) + assert.match(res.stdout, /show/) + assert.match(res.stdout, /unset/) + assert.match(res.stdout, /list/) + }) +})