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
68 changes: 68 additions & 0 deletions src/core/usage-policy/format.js
Original file line number Diff line number Diff line change
@@ -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)
}
7 changes: 7 additions & 0 deletions src/core/usage-policy/index.js
Original file line number Diff line number Diff line change
@@ -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'
102 changes: 102 additions & 0 deletions src/core/usage-policy/matcher.js
Original file line number Diff line number Diff line change
@@ -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<string, ResolveResult>} */
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 }
}
31 changes: 31 additions & 0 deletions src/core/usage-policy/types.d.ts
Original file line number Diff line number Diff line change
@@ -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
}
146 changes: 146 additions & 0 deletions test/core/usage-policy.test.js
Original file line number Diff line number Diff line change
@@ -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<string, string>} files
*/
function fakeFs(files) {
const reads = /** @type {Record<string, number>} */ ({})
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')
})