diff --git a/src/core/usage-policy/format.js b/src/core/usage-policy/format.js new file mode 100644 index 0000000..87c17fc --- /dev/null +++ b/src/core/usage-policy/format.js @@ -0,0 +1,68 @@ +// @ts-check + +/** + * @import { UsageClass, ParseResult } from '../../../src/core/usage-policy/types.js' + */ + +// V1 implements exactly the `ignore` class. The set grows additively when +// `local-only` ships (LLP 0051); until then any other token hits the fail-safe. +const IMPLEMENTED = new Set(['ignore']) + +/** + * Parse a `.hypignore` body into a usage class. + * + * Strip `#` comments and blank lines; the first remaining token names the + * class. An empty or comment-only file means `ignore`, preserving the skill + * notes' promise that an empty `.hypignore` opts the tree out. A token the + * running version does not implement resolves to `ignore` (the most + * restrictive class) and surfaces a `warn` string for the caller to log: + * the safe failure for a privacy control is "suppress more", never + * "record-and-export something the user flagged". + * + * Reserved in-file path patterns are parsed-but-ignored in V1: only the first + * token of the first meaningful line is read. + * + * @ref LLP 0049#file-format [implements]: strip # comments and blanks; first token is the class; empty/comment-only => ignore + * @ref LLP 0049#fail-safe [implements]: unknown/unimplemented class token => ignore (most restrictive) + warn + * @param {string} body + * @returns {ParseResult} + */ +export function parseHypignore(body) { + const token = firstToken(body) + if (token === null) return { class: 'ignore', declared: null } + if (IMPLEMENTED.has(token)) { + return { class: /** @type {UsageClass} */ (token), declared: token } + } + return { + class: 'ignore', + declared: token, + warn: `unimplemented .hypignore usage class "${token}"; treating as "ignore" (most restrictive)`, + } +} + +/** + * First non-comment, non-blank token of a `.hypignore` body, or null when the + * body is empty or comment-only. + * + * @param {string} body + * @returns {string|null} + */ +function firstToken(body) { + for (const rawLine of String(body).split(/\r?\n/)) { + const line = stripComment(rawLine).trim() + if (line === '') continue + return line.split(/\s+/)[0] + } + return null +} + +/** + * Drop an inline `#` comment from a line. + * + * @param {string} line + * @returns {string} + */ +function stripComment(line) { + const hash = line.indexOf('#') + return hash === -1 ? line : line.slice(0, hash) +} diff --git a/src/core/usage-policy/index.js b/src/core/usage-policy/index.js new file mode 100644 index 0000000..95adf22 --- /dev/null +++ b/src/core/usage-policy/index.js @@ -0,0 +1,7 @@ +// @ts-check + +// Public API for the `.hypignore` folder-scoped usage policy (LLP 0049/0050/0052). +// The shared, cwd-agnostic matcher lives in core; the Claude/Codex adapters +// import it exactly as they import `src/core/observability`. +export { parseHypignore } from './format.js' +export { createUsagePolicyResolver } from './matcher.js' diff --git a/src/core/usage-policy/matcher.js b/src/core/usage-policy/matcher.js new file mode 100644 index 0000000..057f3a2 --- /dev/null +++ b/src/core/usage-policy/matcher.js @@ -0,0 +1,102 @@ +// @ts-check + +import nodeFs from 'node:fs' +import path from 'node:path' + +import { parseHypignore } from './format.js' + +/** + * @import { ResolveResult, UsagePolicyResolver } from '../../../src/core/usage-policy/types.js' + */ + +const HYPIGNORE_FILENAME = '.hypignore' + +/** + * Create a usage-policy resolver: given an exchange's `cwd`, walk ancestor + * directories to the nearest `.hypignore` and resolve it to a usage class. + * + * Because V1 has only the `ignore` class and no un-ignore directive, the walk + * collapses to "any `.hypignore` found walking up from a `cwd` governs". The + * resolver is `cwd`-agnostic path logic only: it never inspects rows, so only + * the calling adapter need know which field carries the `cwd`. + * + * Results are memoized per absolute `cwd` so the capture hot path adds no + * unbounded filesystem work. The cache is resolver-lifetime: hold one resolver + * per daemon/backfill run; `hyp ignore --check` constructs a fresh resolver so + * it always reflects disk. + * + * fs is injected for tests and defaults to `node:fs`. + * + * @ref LLP 0050 [implements]: the single shared matcher for all four adapter call sites; no per-adapter copies + * @ref LLP 0049#scope [implements]: gitignore-style ancestor walk from cwd, nearest .hypignore wins; per-cwd cache (R6) + * @param {object} [fs] + * @param {(path: string, encoding: 'utf8') => string} [fs.readFileSync] + * @param {(path: string) => boolean} [fs.existsSync] + * @returns {UsagePolicyResolver} + */ +export function createUsagePolicyResolver({ + readFileSync = nodeFs.readFileSync, + existsSync = nodeFs.existsSync, +} = {}) { + /** @type {Map} */ + const cache = new Map() + + /** + * @param {string} cwd + * @returns {ResolveResult} + */ + function resolve(cwd) { + const key = path.resolve(cwd) + const cached = cache.get(key) + if (cached) return cached + const result = walk(key) + cache.set(key, result) + return result + } + + /** + * @param {string} startDir + * @returns {ResolveResult} + */ + function walk(startDir) { + let dir = startDir + while (true) { + const candidate = path.join(dir, HYPIGNORE_FILENAME) + if (existsSync(candidate)) { + const parsed = parseHypignore(safeRead(candidate)) + return { class: parsed.class, governedBy: candidate, declared: parsed.declared } + } + const parent = path.dirname(dir) + if (parent === dir) break // reached the filesystem root + dir = parent + } + // Nothing governs: the implicit `full` default (LLP 0049 #classes). + return { class: 'full', governedBy: null, declared: null } + } + + /** + * Read a governing `.hypignore`, failing safe to an empty body (which the + * format parses as `ignore`) when the file exists but cannot be read: an + * uninterpretable privacy signal must suppress, never record. + * + * @param {string} file + * @returns {string} + */ + function safeRead(file) { + try { + return String(readFileSync(file, 'utf8')) + } catch { + return '' + } + } + + /** + * @param {string} cwd + * @returns {boolean} + */ + function isIgnored(cwd) { + return resolve(cwd).class === 'ignore' + } + + return { resolve, isIgnored } +} diff --git a/src/core/usage-policy/types.d.ts b/src/core/usage-policy/types.d.ts new file mode 100644 index 0000000..14c1ec3 --- /dev/null +++ b/src/core/usage-policy/types.d.ts @@ -0,0 +1,31 @@ +// Shared types for the `.hypignore` folder-scoped usage policy. +// See LLP 0049 (spec), LLP 0050 (enforcement decision), LLP 0052 (design). + +// V1 ships `ignore`; `local-only` is reserved (LLP 0051) and `full` is the +// implicit default when nothing governs (LLP 0049 #classes). +export type UsageClass = 'ignore' | 'local-only' | 'full' + +// The result of parsing a single `.hypignore` body. `declared` is the raw +// token read before the fail-safe; `warn` is present only when the declared +// token was unknown/unimplemented and was clamped to `ignore`. +export interface ParseResult { + class: UsageClass + declared: string | null + warn?: string +} + +// The result of resolving a `cwd` against the nearest governing `.hypignore`. +// `class` is the resolved, implemented class (`full` when nothing governs); +// `governedBy` is the absolute path of the nearest governing file, or null; +// `declared` is the raw token before fail-safe (null when nothing governs or +// the file was empty/comment-only). +export interface ResolveResult { + class: UsageClass + governedBy: string | null + declared: string | null +} + +export interface UsagePolicyResolver { + resolve(cwd: string): ResolveResult + isIgnored(cwd: string): boolean +} diff --git a/test/core/usage-policy.test.js b/test/core/usage-policy.test.js new file mode 100644 index 0000000..ca8e256 --- /dev/null +++ b/test/core/usage-policy.test.js @@ -0,0 +1,146 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { parseHypignore, createUsagePolicyResolver } from '../../src/core/usage-policy/index.js' + +// --- format.js: parseHypignore ------------------------------------------- + +test('parseHypignore: empty body => ignore (the empty-file opt-out)', () => { + const result = parseHypignore('') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, null) + assert.equal(result.warn, undefined) +}) + +test('parseHypignore: comment-only/blank body => ignore', () => { + const result = parseHypignore('# just a note\n\n \n#another\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, null) + assert.equal(result.warn, undefined) +}) + +test('parseHypignore: recognized `ignore` token => ignore, no warn', () => { + const result = parseHypignore('# header\nignore\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'ignore') + assert.equal(result.warn, undefined) +}) + +test('parseHypignore: unknown token => ignore + warn (fail-safe)', () => { + const result = parseHypignore('mystery-class\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'mystery-class') + assert.match(String(result.warn), /mystery-class/) + assert.match(String(result.warn), /ignore/) +}) + +test('parseHypignore: reserved `local-only` => ignore + warn in V1 (fail-safe)', () => { + const result = parseHypignore('local-only\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'local-only') + assert.match(String(result.warn), /local-only/) +}) + +test('parseHypignore: first token wins; trailing path patterns are parsed-but-ignored', () => { + const result = parseHypignore('ignore secrets/\n# trailing comment\n') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'ignore') +}) + +// --- matcher.js: createUsagePolicyResolver ------------------------------- + +/** + * Build an injectable fs over a fixed map of `.hypignore` file -> contents. + * Tracks read counts so the cache can be asserted. + * + * @param {Record} files + */ +function fakeFs(files) { + const reads = /** @type {Record} */ ({}) + return { + reads, + /** @param {string} p */ + existsSync: (p) => Object.prototype.hasOwnProperty.call(files, p), + /** @param {string} p */ + readFileSync: (p) => { + reads[p] = (reads[p] ?? 0) + 1 + return files[p] ?? '' + }, + } +} + +test('resolve: no .hypignore anywhere => full, governedBy null', () => { + const fs = fakeFs({}) + const resolver = createUsagePolicyResolver(fs) + const result = resolver.resolve('/work/repo/sub') + assert.equal(result.class, 'full') + assert.equal(result.governedBy, null) + assert.equal(result.declared, null) + assert.equal(resolver.isIgnored('/work/repo/sub'), false) +}) + +test('resolve: nearest ancestor .hypignore wins', () => { + const fs = fakeFs({ + '/work/repo/.hypignore': '', + '/work/repo/sub/.hypignore': 'ignore\n', + }) + const resolver = createUsagePolicyResolver(fs) + + const deep = resolver.resolve('/work/repo/sub/deeper/leaf') + assert.equal(deep.class, 'ignore') + assert.equal(deep.governedBy, '/work/repo/sub/.hypignore') + + const shallow = resolver.resolve('/work/repo/other') + assert.equal(shallow.class, 'ignore') + assert.equal(shallow.governedBy, '/work/repo/.hypignore') +}) + +test('resolve: walks all the way to the filesystem root', () => { + const fs = fakeFs({ '/.hypignore': 'ignore\n' }) + const resolver = createUsagePolicyResolver(fs) + const result = resolver.resolve('/a/b/c/d/e') + assert.equal(result.class, 'ignore') + assert.equal(result.governedBy, '/.hypignore') +}) + +test('resolve: unimplemented class in a governing file fails safe to ignore', () => { + const fs = fakeFs({ '/work/repo/.hypignore': 'local-only\n' }) + const resolver = createUsagePolicyResolver(fs) + const result = resolver.resolve('/work/repo/sub') + assert.equal(result.class, 'ignore') + assert.equal(result.declared, 'local-only') + assert.equal(result.governedBy, '/work/repo/.hypignore') +}) + +test('resolve: per-cwd cache is stable and reads the file once', () => { + const fs = fakeFs({ '/work/repo/.hypignore': 'ignore\n' }) + const resolver = createUsagePolicyResolver(fs) + + const first = resolver.resolve('/work/repo/sub') + const second = resolver.resolve('/work/repo/sub') + assert.equal(first, second) // same memoized object + assert.deepEqual(first, { class: 'ignore', governedBy: '/work/repo/.hypignore', declared: 'ignore' }) + assert.equal(fs.reads['/work/repo/.hypignore'], 1) + + // isIgnored shares the same cache: still one read. + assert.equal(resolver.isIgnored('/work/repo/sub'), true) + assert.equal(fs.reads['/work/repo/.hypignore'], 1) +}) + +test('resolve: relative cwd is normalized before caching', () => { + const fs = fakeFs({}) + const resolver = createUsagePolicyResolver(fs) + // Should not throw and should resolve against an absolute key. + const result = resolver.resolve('.') + assert.equal(result.class, 'full') +}) + +test('createUsagePolicyResolver defaults fs to node:fs when none injected', () => { + // A directory tree with no .hypignore resolves to full without throwing. + const resolver = createUsagePolicyResolver() + const result = resolver.resolve(process.cwd()) + assert.ok(result.class === 'full' || result.class === 'ignore') + assert.equal(typeof resolver.isIgnored(process.cwd()), 'boolean') +})