diff --git a/.env.example b/.env.example index 0d09d25..af623f9 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,15 @@ SECURITY_EVENT_HASH_KEY= VITE_POSTHOG_PROJECT_TOKEN= VITE_POSTHOG_HOST= VITE_GA_MEASUREMENT_ID= + +# Ownership ingestion — Phase 1C internal gate (DO NOT ENABLE in production +# until the ownership release train ships; this is documentation only). +# Hard-off by construction: ingestion is allowed ONLY when BOTH hold — +# 1. OWNERSHIP_INGEST_ENABLED is exactly the literal string 'true' +# 2. the repository's INTERNAL uuid (repositories.id) is listed in +# OWNERSHIP_CANARY_REPOSITORY_IDS +# '1', 'yes', wildcards, names, or logins enable nothing. +OWNERSHIP_INGEST_ENABLED=false +# Comma-separated internal repository UUIDs only — never repo names, +# full_name, or GitHub logins. Empty/malformed = nothing can ingest. +OWNERSHIP_CANARY_REPOSITORY_IDS= diff --git a/docs/ownership-data-privacy-spec.md b/docs/ownership-data-privacy-spec.md index 9c9d9ed..5eb5aff 100644 --- a/docs/ownership-data-privacy-spec.md +++ b/docs/ownership-data-privacy-spec.md @@ -1,6 +1,6 @@ # Ownership Data & Privacy Contract — Production Specification -> **Status: proposed production contract — no ownership persistence implemented yet.** +> **Status: schema foundation deployed; ingestion and UI not yet live.** > > This document is the authoritative implementation contract for the Code > Ownership Over Time feature. It translates the approved architecture @@ -9,9 +9,19 @@ > into an exact data contract that Phase 1B (schema + ingestion) and Phase 1C > (dashboard) must implement and that review must enforce. > -> **Nothing in this document is live.** Where the text describes ownership -> behavior it describes *future behavior once ownership ships*. Current -> production behavior is described in §2 and cited against real files. +> **What is live today:** migration `012_ownership.sql` is applied in +> production — the four ownership tables exist with RLS/grants/composite-FK +> foundations, and the dormant `dash_ownership_*` SECURITY INVOKER RPCs are +> deployed. All four tables hold **zero rows**. +> +> **What is NOT live:** no ownership ingestion runs; no ownership data is +> collected or written; `OWNERSHIP_INGEST_ENABLED` is not enabled anywhere; +> no ownership UI exists; the public trust record +> (`src/security/SecurityPrivacyPage.tsx`) has NOT been amended — it changes +> only in the release train where collection actually begins (§17). Where +> the text describes ownership behavior it describes *future behavior once +> ownership ships*. Current production behavior is described in §2 and cited +> against real files. Companion documents: @@ -538,6 +548,33 @@ Rules: by reading back that scope's existing provisional aggregate rows (upserts are set-valued, idempotent) — raw nodes are never re-storable. +### 9.1 Persistence handoff — hard constraint for Phase 1C-B (BLOCKER) + +The Phase 1C-A reducer is correct **only** when it sees the complete node +set for each scope-period it finalizes. The following are hard constraints, +not implementation details, and they block all ownership writes until the +persistence tranche resolves them: + +- **Page-local Top-25/Other folding is invalid.** The Top-25 boundary is + defined over a *complete* scope-month's contributor set. Folding per + fetched page fabricates wrong membership (a contributor who would rank + inside the full-month Top-25 can be spilled, or vice versa). +- `human_contributor_count` must be computed over the **full transient + contributor set** for the scope-month — never a page-local or folded + subset. It must never silently cap at the Top-N boundary. +- **Overflow-only contributor identities must not be durably stored merely + to simplify paging** — §6.4 durable-identity minimization is a hard rule, + not a convenience; writing identity rows "temporarily" is still writing. +- **Advancing a GraphQL cursor independently from aggregate state can + double-count or skip a page.** Aggregate written then crash before cursor + → page reduced twice; cursor written then crash before aggregate → page + never counted. Cursor/checkpoint writes must be **atomic with the + aggregate state they describe**. +- Phase 1C-B must therefore design **and prove** a crash-safe + accumulation/checkpoint strategy — atomic page checkpoints or + equivalent set-valued, idempotent writes — **before any ownership write + path is implemented**. This is an explicit blocker. + --- ## 10. Production data model — final candidate @@ -762,9 +799,11 @@ Mirroring the audited production convention (§2.4): --- -## 16. Dashboard API contract (specification only — not implemented) +## 16. Dashboard API contract (RPC foundations deployed dormant; consumer not implemented) -A future `dash_ownership_*` RPC family feeds the ownership section. Smallest +The `dash_ownership_*` invoker RPCs are deployed (migration 012) but dormant +— `active_version` is never set, so they return nothing. A future API layer +feeds the ownership section through them. Smallest useful payload shape per repository: ```jsonc diff --git a/lib/ownership/contributors.mjs b/lib/ownership/contributors.mjs new file mode 100644 index 0000000..465cdc7 --- /dev/null +++ b/lib/ownership/contributors.mjs @@ -0,0 +1,106 @@ +// Contributor identity classification for ownership ingestion — pure. +// +// Contract (spec §5, §6): +// - linked contributor durable identity is the GitHub GraphQL User.id +// (author.user.id); login is display-only and must never be a join key. +// - email, author/committer names, and numeric databaseId are never +// requested, derived, or stored. +// - author.user == null → ONE unresolved 'unlinked' bucket per repository. +// It is unresolved attribution, NOT a person — never pseudonymized by +// name or email. +// - 'other' is the synthetic aggregate of overflow LINKED contributors. +// - bots are linked identities flagged is_bot; they are not people for +// human-only metric purposes (§6.5). + +import { createHash } from 'node:crypto' + +export const UNLINKED_KEY = 'unlinked' +export const OTHER_KEY = 'other' + +const CONTRIBUTOR_DOMAIN = 'devledger-ownership-contributor-v1' + +const sha256 = (s) => createHash('sha256').update(s).digest('hex') + +// Repository-scoped opaque contributor key. Deterministic across processes, +// stable under login renames (keyed on the node id only), and scoped by the +// repository boundary so the same GitHub account never shares a key across +// repositories. Logins/emails/names never enter the hash. +export function contributorKey(repositoryId, githubNodeId) { + if (typeof repositoryId !== 'string' || repositoryId === '') { + throw new TypeError('contributorKey requires a repository id') + } + if (typeof githubNodeId !== 'string' || githubNodeId === '') { + throw new TypeError('contributorKey requires a GitHub node id') + } + return sha256(`${CONTRIBUTOR_DOMAIN}\0${repositoryId}\0${githubNodeId}`) +} + +// Conservative bot heuristic on the login only: GitHub App/bot accounts use +// the conventional '[bot]' login suffix (dependabot[bot], github-actions[bot] +// …). No broader username guessing — a login merely containing "bot" is not +// classified as a bot. +export function isBotLogin(login) { + return typeof login === 'string' && /\[bot\]$/i.test(login.trim()) +} + +// Synthetic bucket shapes — fixed fields, never carry GitHub identity. +export function unlinkedContributor() { + return { + identity_kind: 'unlinked', + contributor_key: UNLINKED_KEY, + github_node_id: null, + display_login: null, + is_bot: false, + } +} + +export function otherContributor() { + return { + identity_kind: 'aggregate', + contributor_key: OTHER_KEY, + github_node_id: null, + display_login: null, + is_bot: false, + } +} + +// Provider data that violates the identity contract is a data-contract +// error, never unresolved attribution. The message deliberately carries no +// node id, login, repository id, path, or response content. +export class OwnershipIdentityError extends Error { + constructor() { + super('malformed contributor identity in GitHub response') + this.name = 'OwnershipIdentityError' + } +} + +// Classify one history/blame node's author.user into a contributor identity. +// authorUser == null/undefined → the single unlinked bucket +// { id: , login: } → linked contributor +// anything else (missing/empty/wrong-typed id or login, non-object value) +// → OwnershipIdentityError — 'unlinked' means ONLY that GitHub returned +// no linked user; malformed provider data must never silently become +// unresolved attribution. +export function classifyAuthorIdentity(authorUser, repositoryId) { + if (authorUser === null || authorUser === undefined) return unlinkedContributor() + if (typeof authorUser !== 'object') throw new OwnershipIdentityError() + const { id, login } = authorUser + if (typeof id !== 'string' || id.trim() === '' + || typeof login !== 'string' || login.trim() === '') { + throw new OwnershipIdentityError() + } + return { + identity_kind: 'linked', + contributor_key: contributorKey(repositoryId, id), + github_node_id: id, + display_login: login, + is_bot: isBotLogin(login), + } +} + +// Human-only metric basis (§6.3, §6.5): concentration/count inputs include +// only linked non-bot identities — unlinked, aggregate, and bots are never +// people for these metrics. +export function isHumanMetricBasis(identity) { + return identity.identity_kind === 'linked' && identity.is_bot === false +} diff --git a/lib/ownership/gate.mjs b/lib/ownership/gate.mjs new file mode 100644 index 0000000..ccef229 --- /dev/null +++ b/lib/ownership/gate.mjs @@ -0,0 +1,62 @@ +// Hard-off feature gate for ownership ingestion. +// +// Contract (docs/ownership-data-privacy-spec.md §21-22): ownership work is +// allowed ONLY when BOTH hold: +// 1. OWNERSHIP_INGEST_ENABLED is exactly the literal string 'true' +// 2. the target repository's INTERNAL uuid is present in +// OWNERSHIP_CANARY_REPOSITORY_IDS (comma-separated) +// Every other state — absent, false, malformed, empty allowlist, repo not +// listed — means DISABLED. No truthiness, no wildcards, no names/logins. +// +// This module is the single entry point a future runner must call BEFORE any +// ownership GitHub request or ownership DB write. Phase 1C-A ships no runner +// and no caller, so the gate can only ever evaluate false in production. + +export const OWNERSHIP_FLAG_ENV = 'OWNERSHIP_INGEST_ENABLED' +export const OWNERSHIP_ALLOWLIST_ENV = 'OWNERSHIP_CANARY_REPOSITORY_IDS' + +// Canonical uuid shape (8-4-4-4-12 hex). Internal repositories.id values are +// Postgres uuids; anything that is not uuid-shaped is rejected, never coerced. +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +export class OwnershipDisabledError extends Error { + constructor() { + // deliberately carries no repository id, flag value, or allowlist content + super('ownership ingestion is disabled for this repository') + this.name = 'OwnershipDisabledError' + } +} + +// Exact literal only. '1', 'yes', 'TRUE ' or any other value disables. +export function isOwnershipIngestEnabled(env = process.env) { + return env[OWNERSHIP_FLAG_ENV] === 'true' +} + +// Validated internal repository uuid allowlist. Malformed entries are dropped, +// whitespace is ignored, duplicates collapse, case is normalized. +export function canaryRepositoryIds(env = process.env) { + const raw = env[OWNERSHIP_ALLOWLIST_ENV] + if (typeof raw !== 'string' || raw.trim() === '') return new Set() + const ids = new Set() + for (const part of raw.split(',')) { + const id = part.trim() + if (UUID_RE.test(id)) ids.add(id.toLowerCase()) + } + return ids +} + +// The one predicate a runner calls: both conditions must hold. +export function ownershipIngestAllowed(repositoryId, env = process.env) { + if (!isOwnershipIngestEnabled(env)) return false + if (typeof repositoryId !== 'string' || !UUID_RE.test(repositoryId)) return false + return canaryRepositoryIds(env).has(repositoryId.toLowerCase()) +} + +// Assertive form for the top of a future ingestion boundary — a disabled gate +// throws before any network or DB work can start. +export function assertOwnershipIngestAllowed(repositoryId, env = process.env) { + if (!ownershipIngestAllowed(repositoryId, env)) { + throw new OwnershipDisabledError() + } +} diff --git a/lib/ownership/history-reducer.mjs b/lib/ownership/history-reducer.mjs new file mode 100644 index 0000000..8315902 --- /dev/null +++ b/lib/ownership/history-reducer.mjs @@ -0,0 +1,187 @@ +// Pure monthly history reducer for ownership ingestion. +// +// Turns a scope's in-memory metadata-only history(path:) node stream into +// bounded candidate aggregates matching the ownership_snapshots contract +// (spec §4.3, §6, §11). NO persistence: no Supabase, no Octokit, no fs, no +// logging — the persistence/resumability tranche owns writes. +// +// Semantics (§16 spec / instruction): +// - each node = ONE commit touching the scope; touch share is a +// maintenance-activity signal, never code volume or expertise. +// - human metrics are computed over the FULL transient contributor set +// BEFORE Top-25 folding; basis = linked && !is_bot only. +// - the unlinked bucket always gets its own row, is never folded into +// 'other', and never counts as a person. +// - 'other' = excess LINKED contributors past the Top-25 cap — it is +// "Other linked contributors", not "other humans"; UI must never label +// it as people. +// - durable contributor candidates exist only for identities individually +// represented in at least one retained scope-period (§6.4 durable +// identity minimization). + +import { + classifyAuthorIdentity, + isHumanMetricBasis, + otherContributor, + unlinkedContributor, + OwnershipIdentityError, + OTHER_KEY, + UNLINKED_KEY, +} from './contributors.mjs' + +export const TOP_LINKED_PER_PERIOD = 25 + +// First instant of the UTC calendar month — never local time. +export function monthStartUtc(iso) { + const d = new Date(iso) + if (Number.isNaN(d.getTime())) throw new TypeError('unparseable committedDate') + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)).toISOString() +} + +// Deterministic 4-decimal share for numeric(5,4) columns. Clamped to [0,1]; +// never NaN/Infinity/-0. Scope totals stay the authoritative denominator — +// shares are NOT force-summed by mutating any one contributor's result. +export function shareOf(part, total) { + if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0) return 0 + let s = Math.round((part / total) * 10000) / 10000 + if (s < 0) s = 0 + if (s > 1) s = 1 + return s === 0 ? 0 : s // normalize -0 +} + +// Deterministic rank for the Top-N fold: touch_commits descending, then the +// opaque contributor key ascending as a stable tie-break. +function compareByTouches(a, b) { + if (b.touches !== a.touches) return b.touches - a.touches + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0 +} + +// nodes: [{ committedDate, author: { user: { id, login } | null } }] +// scope: a scope candidate identity (passed through for labeling only) +// +// HARD CONSTRAINT (spec §9.1): `nodes` must be the COMPLETE set for every +// scope-month this call finalizes — page-local folding produces wrong +// Top-25 membership and wrong human counts; crash-safe accumulation is the +// persistence tranche's explicit blocker. +// +// Returns { scope, months, contributor_candidates } — candidate shapes only; +// nothing here is or may be written to the database in this tranche. +export function reduceHistoryToMonthly({ repositoryId, scope, nodes }) { + const byMonth = new Map() + // contributor_key -> identity + first/last seen (for durable candidates) + const seen = new Map() + + for (const node of Array.isArray(nodes) ? nodes : []) { + if (!node || node.committedDate == null) { + throw new TypeError('history node missing committedDate') + } + const period = monthStartUtc(node.committedDate) + if (node.author != null && typeof node.author !== 'object') { + throw new OwnershipIdentityError() + } + const identity = classifyAuthorIdentity(node.author?.user, repositoryId) + + let month = byMonth.get(period) + if (!month) { + month = { total: 0, unlinked: 0, linked: new Map(), humanKeys: new Set() } + byMonth.set(period, month) + } + month.total += 1 + + if (identity.identity_kind === 'unlinked') { + month.unlinked += 1 + continue + } + + const cur = month.linked.get(identity.contributor_key) ?? { key: identity.contributor_key, touches: 0, is_bot: identity.is_bot } + cur.touches += 1 + month.linked.set(identity.contributor_key, cur) + if (isHumanMetricBasis(identity)) month.humanKeys.add(identity.contributor_key) + + // transient durable-candidate bookkeeping — only used when the identity + // ends up individually represented in a retained period. + const prev = seen.get(identity.contributor_key) + seen.set(identity.contributor_key, { + identity_kind: 'linked', + contributor_key: identity.contributor_key, + github_node_id: identity.github_node_id, + display_login: identity.display_login, + is_bot: identity.is_bot, + first_seen_at: prev + ? prev.first_seen_at < node.committedDate ? prev.first_seen_at : node.committedDate + : node.committedDate, + last_seen_at: prev + ? prev.last_seen_at > node.committedDate ? prev.last_seen_at : node.committedDate + : node.committedDate, + }) + } + + const months = [] + const represented = new Set() + + for (const [period, m] of [...byMonth.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) { + const ranked = [...m.linked.values()].sort(compareByTouches) + const top = ranked.slice(0, TOP_LINKED_PER_PERIOD) + const overflow = ranked.slice(TOP_LINKED_PER_PERIOD) + + const contributors = top.map((c) => { + represented.add(c.key) + return { + contributor_key: c.key, + identity_kind: 'linked', + is_bot: c.is_bot, + touch_commits: c.touches, + touch_share: shareOf(c.touches, m.total), + } + }) + + if (m.unlinked > 0) { + represented.add(UNLINKED_KEY) + contributors.push({ + contributor_key: UNLINKED_KEY, + identity_kind: 'unlinked', + is_bot: false, + touch_commits: m.unlinked, + touch_share: shareOf(m.unlinked, m.total), + }) + } + + if (overflow.length > 0) { + represented.add(OTHER_KEY) + const otherTouches = overflow.reduce((n, c) => n + c.touches, 0) + contributors.push({ + contributor_key: OTHER_KEY, + identity_kind: 'aggregate', + is_bot: false, + touch_commits: otherTouches, + touch_share: shareOf(otherTouches, m.total), + }) + } + + months.push({ + period_start: period, + totals: { + total_touch_commits: m.total, + // computed from the full transient set BEFORE folding — never + // capped by the Top-25 boundary. + human_contributor_count: m.humanKeys.size, + unlinked_touch_share: shareOf(m.unlinked, m.total), + }, + contributors, + }) + } + + // Durable identity minimization: a linked contributor becomes a durable + // candidate ONLY if individually represented somewhere — overflow-only + // identities stay transient and produce no identity row (§6.4). The two + // synthetic buckets get durable rows when they appear. + const contributor_candidates = [] + for (const key of represented) { + if (key === UNLINKED_KEY) contributor_candidates.push(unlinkedContributor()) + else if (key === OTHER_KEY) contributor_candidates.push(otherContributor()) + else contributor_candidates.push(seen.get(key)) + } + contributor_candidates.sort((a, b) => (a.contributor_key < b.contributor_key ? -1 : 1)) + + return { scope, months, contributor_candidates } +} diff --git a/lib/ownership/queries.mjs b/lib/ownership/queries.mjs new file mode 100644 index 0000000..8caee6e --- /dev/null +++ b/lib/ownership/queries.mjs @@ -0,0 +1,121 @@ +// Production GitHub GraphQL query builders for ownership ingestion. +// +// These are the audited metadata-only production implementations — promoted +// from the measured spike family (scripts/spikes/ownership/queries.mjs, +// results in docs/ownership-graphql-spike-results.md) but owned here; runtime +// code never imports the spike module. +// +// HARD CONTRACT (spec §5, §7): no query built here may select source-bearing +// or identity-creep fields. Blame ranges, path-filtered history metadata, +// author identity (user id + login only), and rate-limit telemetry are the +// entire allowed payload. The static audit (assertQueriesMetadataOnly plus +// test/ownership-queries.test.mjs) inspects the query STRINGS so a future +// edit cannot silently widen them. + +// Fields that must never appear in an ownership production query. 'files', +// 'additions' and 'deletions' are listed even though they carry no source — +// path-filtered history returns them as WHOLE-COMMIT statistics, not +// directory churn (measured in the spike), so they can never serve as +// path-volume metrics. +export const FORBIDDEN_OWNERSHIP_FIELDS = [ + 'text', 'byteSize', 'patch', 'diff', + 'message', 'messageHeadline', 'messageBody', + 'email', 'signature', 'committer', 'databaseId', + 'files', 'changedFiles', 'additions', 'deletions', + 'isTruncated', 'treeHash', 'zipballUrl', 'tarballUrl', +] + +export const FORBIDDEN_OWNERSHIP_FIELD_RE = new RegExp( + `\\b(${FORBIDDEN_OWNERSHIP_FIELDS.join('|')})\\b`, +) + +// The ONLY identity selection allowed anywhere in an ownership query. +export const AUTHOR_USER_FIELDS = 'user { id login }' + +const RATE_LIMIT = 'rateLimit { cost remaining resetAt }' + +// Alias ceiling for batched blame — the spike measured 1/5/10/20/50 aliases; +// 50 is the tested production ceiling and may not be raised without another +// measurement pass. +export const BLAME_BATCH_LIMIT = 50 + +// history(path:) — V1 monthly maintenance/touch signal. +// Nodes carry ONLY the reducer's needs: committedDate + linked identity. +// Deliberately absent: oid (not needed by the Phase-1C reducer), all +// whole-commit stats, message/email/name, files. $path may be null for the +// repository scope (unfiltered history). +export function historyPathQuery() { + return `query ($owner: String!, $name: String!, $path: String, $cursor: String) { + repository(owner: $owner, name: $name) { + defaultBranchRef { + target { + ... on Commit { + history(first: 100, path: $path, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + committedDate + author { ${AUTHOR_USER_FIELDS} } + } + } + } + } + } + } + ${RATE_LIMIT} +}` +} + +// Alias-batched HEAD blame, up to BLAME_BATCH_LIMIT file paths per request. +// Phase 1C-A builds NO blame callsite and no blame reducer — this builder is +// promoted only to fix the audited query shape for a later tranche. Nothing +// may call it from runtime. +export function batchedBlameQuery(paths) { + if (!Array.isArray(paths) || paths.length === 0) { + throw new RangeError('batchedBlameQuery requires at least one path') + } + if (paths.length > BLAME_BATCH_LIMIT) { + throw new RangeError( + `batchedBlameQuery is capped at ${BLAME_BATCH_LIMIT} aliases per request`, + ) + } + const varDecls = paths.map((_, i) => `$p${i}: String!`).join(', ') + const aliases = paths + .map( + (_, i) => `b${i}: blame(path: $p${i}) { + ranges { startingLine endingLine commit { oid committedDate author { ${AUTHOR_USER_FIELDS} } } } + }`, + ) + .join('\n') + return `query ($owner: String!, $name: String!, ${varDecls}) { + repository(owner: $owner, name: $name) { + object(expression: "HEAD") { + ... on Commit { + ${aliases} + } + } + } + ${RATE_LIMIT} +}` +} + +// Static audit — throws when a built query widened past the metadata +// contract. Also verifies every user-selection is exactly id+login so a +// stray name/email field cannot hide inside an author block. +export function assertOwnershipQuerySafe(query) { + const m = query.match(FORBIDDEN_OWNERSHIP_FIELD_RE) + if (m) throw new Error(`ownership query selects forbidden field: ${m[0]}`) + const userBlocks = query.match(/user\s*\{[^}]*\}/g) ?? [] + for (const block of userBlocks) { + const fields = block.replace(/^user\s*\{|\}$/g, '').trim().split(/\s+/) + for (const f of fields) { + if (f !== 'id' && f !== 'login') { + throw new Error(`ownership query selects non-contract user field: ${f}`) + } + } + } + return query +} + +// Every builder output passes the same audit — run at module load so the +// constant query shapes are provably within contract before any use. +assertOwnershipQuerySafe(historyPathQuery()) diff --git a/lib/ownership/scopes.mjs b/lib/ownership/scopes.mjs new file mode 100644 index 0000000..2ec3a66 --- /dev/null +++ b/lib/ownership/scopes.mjs @@ -0,0 +1,206 @@ +// Scope derivation for ownership ingestion — pure, no I/O. +// +// Contract (spec §4.1, §8, RFC §18): persisted scopes are the repository +// scope plus DIRECTORY scopes only. File paths may exist transiently in +// memory during enumeration but a scope candidate for durable persistence +// can never be a file. Vendor/generated/build/dependency/submodule trees +// are filtered in memory and NEVER become ownership_scopes rows — not even +// as excluded=true rows; that flag is reserved for the cardinality cap. + +import { createHash } from 'node:crypto' + +export const MAX_DEPTH = 4 +export const MAX_DISPLAY_PATH_LENGTH = 200 +export const MAX_DIR_SCOPES = 500 + +// Repository-scope hash sentinel: a fixed constant carrying no repository +// name, owner login, full_name, or GitHub id. Uniqueness comes from the +// (repository_id, scope_path_hash) pair, so one sentinel is safe. The 'dir' +// prefix on directory hashes gives the two namespaces domain separation. +export const REPO_SCOPE_SENTINEL = 'devledger-ownership-scope-v1:repo' +const DIR_HASH_PREFIX = 'devledger-ownership-scope-v1:dir:' + +const sha256 = (s) => createHash('sha256').update(s).digest('hex') + +export function repoScopePathHash() { + return sha256(REPO_SCOPE_SENTINEL) +} + +// Deliberately NOT rename-stable: the hash tracks the current normalized +// path (spec §8 — a rename produces a new scope identity in V1). +export function dirScopePathHash(normalizedPath) { + return sha256(DIR_HASH_PREFIX + normalizedPath) +} + +// Path normalization for hashing/persistence: +// canonical '/' separators, no leading/trailing slash, no empty segments. +// Rejects (returns null): non-strings, empty/blank paths, traversal-like +// segments ('.', '..'), and NUL/control characters. +export function normalizePath(raw) { + if (typeof raw !== 'string') return null + const collapsed = raw.replace(/\\/g, '/').replace(/\/+/g, '/') + const trimmed = collapsed.replace(/^\/+|\/+$/g, '') + if (trimmed === '') return null + const segments = trimmed.split('/') + for (const seg of segments) { + if (seg === '' || seg === '.' || seg === '..' || seg.trim() === '') return null + if (/[\x00-\x1f]/.test(seg)) return null + } + return segments.join('/') +} + +export function pathDepth(normalizedPath) { + return normalizedPath === null ? 0 : normalizedPath.split('/').length +} + +// A normalized directory path qualifies as a persisted dir scope only when +// it fits the bounded envelope. +export function isValidDirScopePath(normalizedPath) { + return ( + normalizedPath !== null && + normalizedPath.length <= MAX_DISPLAY_PATH_LENGTH && + pathDepth(normalizedPath) <= MAX_DEPTH + ) +} + +// ── exclusion policy ────────────────────────────────────────────────────── +// Conservative, high-confidence classes only — this list must never grow +// into a generic ignore file that silently drops legitimate source dirs. +// Matching is per path SEGMENT, so nested vendored trees (e.g. +// packages/x/node_modules) are caught without enumerating them. +// +// dependency / vendored: node_modules, bower_components, vendor, Pods, +// Python virtualenvs (.venv/venv/.tox) +// generated / build output: dist, build, out, target, obj, coverage, +// generated/__generated__, framework output dirs (.next/.nuxt/.output/ +// .svelte-kit/.turbo/.cache) +// VCS internals (defensive): .git/.svn/.hg — a real tree never contains +// these, but a hostile path name should not become a scope either. +// Submodules are excluded by git ENTRY TYPE ('commit' gitlink), never by a +// filename guess — see deriveScopeCandidates. +const EXCLUDED_SEGMENT_NAMES = new Set([ + 'node_modules', 'bower_components', 'vendor', 'pods', + '.venv', 'venv', '.tox', + 'dist', 'build', 'out', 'target', 'obj', 'coverage', + 'generated', '__generated__', + '.next', '.nuxt', '.output', '.svelte-kit', '.turbo', '.cache', + '.git', '.svn', '.hg', +]) + +export function isExcludedPath(normalizedPath) { + if (normalizedPath === null) return false + return normalizedPath + .split('/') + .some((seg) => EXCLUDED_SEGMENT_NAMES.has(seg.toLowerCase())) +} + +// ── binary-blob exclusion ───────────────────────────────────────────────── +// Conservative extension classes only — high-confidence non-source content. +// Deliberately NOT an asset blacklist: SVG (XML text), source maps, lockfiles, +// configs, scripts, and ordinary text/source formats stay eligible. A blob +// classified binary never causes ancestor directories to become scopes, so a +// directory containing only binaries produces no scope. +const BINARY_EXTENSIONS = new Set([ + // raster images + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'bmp', 'tiff', 'tif', + 'heic', 'heif', 'psd', + // audio / video + 'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', + 'mp4', 'webm', 'mov', 'avi', 'mkv', 'wmv', 'm4v', + // archives / packages + 'zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', + 'jar', 'war', 'ear', 'apk', 'dmg', 'iso', 'msi', 'nupkg', + // compiled objects / binaries + 'exe', 'dll', 'so', 'dylib', 'a', 'o', 'obj', 'class', + 'pyc', 'pyo', 'wasm', 'lib', 'bin', + // fonts + 'woff', 'woff2', 'ttf', 'otf', 'eot', + // binary documents / data stores + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'db', 'sqlite', 'sqlite3', 'parquet', +]) + +export function isBinaryBlobPath(normalizedPath) { + if (normalizedPath === null) return false + const name = normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1) + const dot = name.lastIndexOf('.') + if (dot <= 0) return false // dotfiles and extensionless names are not binary by convention + return BINARY_EXTENSIONS.has(name.slice(dot + 1).toLowerCase()) +} + +// ── scope candidates ────────────────────────────────────────────────────── + +function dirCandidate(normalizedPath) { + return { + scope_type: 'dir', + display_path: normalizedPath, + depth: pathDepth(normalizedPath), + scope_path_hash: dirScopePathHash(normalizedPath), + } +} + +export function repoScopeCandidate() { + return { + scope_type: 'repo', + display_path: null, + depth: 0, + scope_path_hash: repoScopePathHash(), + } +} + +// Deterministic ordering independent of API/tree response order: +// shallower depth first, then normalized path ascending (UTF-16 code-unit +// comparison — stable across processes and locales). +function compareDirs(a, b) { + if (a.depth !== b.depth) return a.depth - b.depth + return a.display_path < b.display_path ? -1 : a.display_path > b.display_path ? 1 : 0 +} + +// Turn raw tree enumeration output into persistable scope candidates. +// +// entries: [{ path, type }] where type is the git object type — 'tree' +// (directory), 'blob' (file), 'commit' (submodule gitlink), or anything +// else (ignored). +// +// Rules: +// - 'commit' entries are submodules: excluded in memory, never scopes. +// - 'tree' entries never create scopes by themselves — directory +// candidates are derived from ELIGIBLE DESCENDANT BLOBS (Git does not +// track empty directories; a dir containing only binary or +// excluded-class content is not a scope). +// - a 'blob' qualifies only when its path is valid, not exclusion-class, +// and not a binary extension — it then marks every ancestor directory. +// - blob paths themselves NEVER become scope candidates. +// - >MAX_DIR_SCOPES otherwise-valid dirs resolve deterministically by +// compareDirs; overflow is returned (NOT persisted, NOT marked here) +// so the persistence tranche can flag it 'cardinality_cap'. +// +// Returns { scopes, overflow } — 'scopes' always begins with the repo +// scope followed by <= MAX_DIR_SCOPES dir candidates; 'overflow' holds the +// deterministic remainder (possibly empty). No file path ever appears in +// either list. +export function deriveScopeCandidates(entries, { maxDirScopes = MAX_DIR_SCOPES } = {}) { + const dirs = new Set() + for (const entry of Array.isArray(entries) ? entries : []) { + if (!entry || typeof entry.path !== 'string' || entry.type !== 'blob') continue + const normalized = normalizePath(entry.path) + if (normalized === null) continue + if (isExcludedPath(normalized) || isBinaryBlobPath(normalized)) continue + const segments = normalized.split('/') + // ancestors only — the file's own path never enters the scope set + for (let i = 1; i < segments.length; i++) { + dirs.add(segments.slice(0, i).join('/')) + } + } + + const candidates = [] + for (const dir of dirs) { + if (!isValidDirScopePath(dir) || isExcludedPath(dir)) continue + candidates.push(dirCandidate(dir)) + } + candidates.sort(compareDirs) + + const selected = candidates.slice(0, maxDirScopes) + const overflow = candidates.slice(maxDirScopes) + return { scopes: [repoScopeCandidate(), ...selected], overflow } +} diff --git a/security/secret-inventory.json b/security/secret-inventory.json index 18ae1ba..dcad564 100644 --- a/security/secret-inventory.json +++ b/security/secret-inventory.json @@ -133,6 +133,8 @@ { "name": "SESSION_COOKIE_NAME", "purpose": "session cookie name override — defaults to dev_ledger_session" }, { "name": "SUPABASE_DB_HOST", "purpose": "pooler hostname for db:migrate" }, { "name": "SUPABASE_DB_PORT", "purpose": "pooler port for db:migrate" }, - { "name": "SUPABASE_PROJECT_REF", "purpose": "project ref used to derive the pooler host" } + { "name": "SUPABASE_PROJECT_REF", "purpose": "project ref used to derive the pooler host" }, + { "name": "OWNERSHIP_INGEST_ENABLED", "purpose": "Phase-1C internal gate — literal true enables ownership ingestion only for canary-listed repositories" }, + { "name": "OWNERSHIP_CANARY_REPOSITORY_IDS", "purpose": "comma-separated internal repository uuids allowed to ingest ownership; empty disables everywhere" } ] } diff --git a/test/ownership-contributors.test.mjs b/test/ownership-contributors.test.mjs new file mode 100644 index 0000000..9fd9ef5 --- /dev/null +++ b/test/ownership-contributors.test.mjs @@ -0,0 +1,153 @@ +// Phase 1C-A contributor identity contract — node-id durable keys, +// repository scoping, single unlinked bucket, conservative bot heuristic. + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const { + UNLINKED_KEY, + OTHER_KEY, + OwnershipIdentityError, + contributorKey, + isBotLogin, + unlinkedContributor, + otherContributor, + classifyAuthorIdentity, + isHumanMetricBasis, +} = await import('../lib/ownership/contributors.mjs') + +const REPO_A = '11111111-2222-3333-4444-555555555555' +const REPO_B = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + +/* ── linked keys ─────────────────────────────────────────────────────── */ + +test('durable key is derived from repository boundary + GitHub node id', () => { + const k = contributorKey(REPO_A, 'MDQ6VXNlcjE=') + assert.match(k, /^[0-9a-f]{64}$/) + assert.equal(k, contributorKey(REPO_A, 'MDQ6VXNlcjE='), 'deterministic') +}) + +test('login rename does NOT change the contributor key', () => { + const before = classifyAuthorIdentity({ id: 'N_1', login: 'oldname' }, REPO_A) + const after = classifyAuthorIdentity({ id: 'N_1', login: 'newname' }, REPO_A) + assert.equal(before.contributor_key, after.contributor_key) + assert.equal(after.display_login, 'newname', 'display_login is refreshed, key is not') +}) + +test('same GitHub node id in two repositories gets different keys', () => { + assert.notEqual(contributorKey(REPO_A, 'N_1'), contributorKey(REPO_B, 'N_1')) +}) + +test('key is opaque — embeds no login, node id, or repo id', () => { + const k = contributorKey(REPO_A, 'N_secret_node') + assert.ok(!k.includes('N_secret')) + assert.ok(!k.includes('11111111')) +}) + +test('missing repo id or node id is a programming error, not a bucket', () => { + assert.throws(() => contributorKey('', 'N_1'), TypeError) + assert.throws(() => contributorKey(REPO_A, ''), TypeError) + assert.throws(() => contributorKey(REPO_A, null), TypeError) +}) + +/* ── classification ──────────────────────────────────────────────────── */ + +test('linked identity carries node id + login; reserved keys never collide', () => { + const c = classifyAuthorIdentity({ id: 'N_9', login: 'alice' }, REPO_A) + assert.equal(c.identity_kind, 'linked') + assert.equal(c.github_node_id, 'N_9') + assert.equal(c.display_login, 'alice') + assert.equal(c.is_bot, false) + assert.ok(c.contributor_key !== UNLINKED_KEY && c.contributor_key !== OTHER_KEY) +}) + +test('author.user == null → the single unlinked bucket', () => { + const c = classifyAuthorIdentity(null, REPO_A) + assert.deepEqual(c, { + identity_kind: 'unlinked', + contributor_key: 'unlinked', + github_node_id: null, + display_login: null, + is_bot: false, + }) + assert.deepEqual(classifyAuthorIdentity(undefined, REPO_A), c) + assert.deepEqual(unlinkedContributor(), c) +}) + +test('malformed linked identity THROWS — never silently becomes unlinked', () => { + // a present-but-broken user object is a data-contract violation, not + // unresolved attribution: 'unlinked' means ONLY author.user == null + for (const bad of [ + { id: '', login: 'x' }, // empty id + { id: ' ', login: 'x' }, // whitespace-only id + { id: 'N_1' }, // missing login + { login: 'x' }, // missing id + {}, // both missing + { id: 7, login: 'x' }, // wrong id type + { id: 'N_1', login: 7 }, // wrong login type + { id: 'N_1', login: ' ' }, // whitespace-only login + 'a-string', 42, true, // non-object values + ]) { + assert.throws(() => classifyAuthorIdentity(bad, REPO_A), OwnershipIdentityError, JSON.stringify(bad)) + } +}) + +test('thrown identity errors carry no identifying values', () => { + for (const bad of [{ id: 'N_secret_node' }, { id: 'N_x', login: 99 }]) { + try { + classifyAuthorIdentity(bad, REPO_A) + assert.fail('should have thrown') + } catch (e) { + assert.ok(e instanceof OwnershipIdentityError) + for (const leak of ['N_secret_node', 'N_x', '99', REPO_A]) { + assert.ok(!e.message.includes(leak), `error leaked ${leak}`) + } + } + } +}) + +test('synthetic buckets are fixed-shape, identity-free, non-human', () => { + for (const c of [unlinkedContributor(), otherContributor()]) { + assert.equal(c.github_node_id, null) + assert.equal(c.display_login, null) + assert.equal(c.is_bot, false) + assert.equal(isHumanMetricBasis(c), false) + } + assert.equal(otherContributor().identity_kind, 'aggregate') + assert.equal(otherContributor().contributor_key, 'other') +}) + +/* ── bot heuristic ───────────────────────────────────────────────────── */ + +test('[bot] suffix marks linked identities as bots', () => { + for (const login of ['dependabot[bot]', 'github-actions[bot]', 'renovate[bot]', 'x[BOT]']) { + assert.equal(isBotLogin(login), true, login) + assert.equal(classifyAuthorIdentity({ id: 'N_b', login }, REPO_A).is_bot, true) + } +}) + +test('no broad username guessing — plain substrings are not bots', () => { + for (const login of ['robot', 'botany', 'abott', 'bot', 'botsworth', 'mybot', 'bot-user']) { + assert.equal(isBotLogin(login), false, login) + } + for (const bad of [null, undefined, 42]) assert.equal(isBotLogin(bad), false) +}) + +test('human metric basis = linked AND not bot — nothing else', () => { + assert.equal(isHumanMetricBasis(classifyAuthorIdentity({ id: 'N_1', login: 'alice' }, REPO_A)), true) + assert.equal(isHumanMetricBasis(classifyAuthorIdentity({ id: 'N_1', login: 'ci[bot]' }, REPO_A)), false) + assert.equal(isHumanMetricBasis(unlinkedContributor()), false) + assert.equal(isHumanMetricBasis(otherContributor()), false) +}) + +test('identity model never requests or derives email/name/databaseId', async () => { + const { readFileSync } = await import('node:fs') + const { join } = await import('node:path') + const { fileURLToPath } = await import('node:url') + const raw = readFileSync(join(fileURLToPath(import.meta.url), '../../lib/ownership/contributors.mjs'), 'utf8') + // comments may document the prohibition — audit executable code only + const code = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '') + for (const bad of ['email', 'databaseId', 'author.name', 'committer', 'avatar']) { + assert.doesNotMatch(code, new RegExp(`\\b${bad}\\b`, 'i'), `contributors must not handle ${bad}`) + } +}) diff --git a/test/ownership-gate.test.mjs b/test/ownership-gate.test.mjs new file mode 100644 index 0000000..3a9fa45 --- /dev/null +++ b/test/ownership-gate.test.mjs @@ -0,0 +1,93 @@ +// Phase 1C-A gate contract — the hard-off feature boundary every future +// ownership runner must pass before any GitHub request or DB write. + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const { + OWNERSHIP_FLAG_ENV, + OWNERSHIP_ALLOWLIST_ENV, + OwnershipDisabledError, + isOwnershipIngestEnabled, + canaryRepositoryIds, + ownershipIngestAllowed, + assertOwnershipIngestAllowed, +} = await import('../lib/ownership/gate.mjs') + +const REPO_A = '11111111-2222-3333-4444-555555555555' +const REPO_B = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + +const env = (flag, list) => ({ + [OWNERSHIP_FLAG_ENV]: flag, + [OWNERSHIP_ALLOWLIST_ENV]: list, +}) + +test('flag absent -> disabled', () => { + assert.equal(isOwnershipIngestEnabled({}), false) + assert.equal(ownershipIngestAllowed(REPO_A, env(undefined, REPO_A)), false) +}) + +test('flag false/malformed -> disabled', () => { + for (const bad of ['false', '1', 'yes', 'TRUE', 'True', ' true', 'on', '0']) { + assert.equal(isOwnershipIngestEnabled(env(bad, REPO_A)), false, `flag '${bad}'`) + assert.equal(ownershipIngestAllowed(REPO_A, env(bad, REPO_A)), false) + } +}) + +test('explicit true is the ONLY enabled literal', () => { + assert.equal(isOwnershipIngestEnabled(env('true', REPO_A)), true) +}) + +test('flag true + allowlist absent/empty -> disabled for every repository', () => { + assert.equal(ownershipIngestAllowed(REPO_A, { [OWNERSHIP_FLAG_ENV]: 'true' }), false) + for (const empty of [undefined, '', ' ', ',', ' , , ']) { + assert.equal(ownershipIngestAllowed(REPO_A, env('true', empty)), false, `allowlist '${empty}'`) + } +}) + +test('flag true + repository not listed -> disabled', () => { + assert.equal(ownershipIngestAllowed(REPO_A, env('true', REPO_B)), false) +}) + +test('flag true + listed valid repository uuid -> enabled', () => { + assert.equal(ownershipIngestAllowed(REPO_A, env('true', REPO_A)), true) + assert.equal(ownershipIngestAllowed(REPO_A, env('true', `${REPO_B},${REPO_A}`)), true) +}) + +test('malformed/non-uuid allowlist entries enable nothing', () => { + for (const bad of ['*', 'all', 'owner/repo', 'some-login', 'not-a-uuid', + '11111111-2222-3333-4444-55555555555', '11111111-2222-3333-4444-555555555555x']) { + assert.equal(ownershipIngestAllowed(REPO_A, env('true', bad)), false, `entry '${bad}'`) + assert.equal(canaryRepositoryIds(env('true', bad)).size, 0) + } +}) + +test('repository argument must itself be a uuid — names/logins never match', () => { + const e = env('true', REPO_A) + for (const bad of ['owner/repo', 'repo-name', 'some-login', '*', '', null, undefined, 42]) { + assert.equal(ownershipIngestAllowed(bad, e), false, `repo '${bad}'`) + } +}) + +test('duplicates and whitespace are harmless; case-insensitive uuids', () => { + const e = env('true', ` ${REPO_A} , ${REPO_A.toUpperCase()}, ${REPO_B} ,, `) + assert.equal(canaryRepositoryIds(e).size, 2) + assert.equal(ownershipIngestAllowed(REPO_A.toUpperCase(), e), true) + assert.equal(ownershipIngestAllowed(REPO_B, e), true) +}) + +test('assertive gate throws OwnershipDisabledError when disabled', () => { + assert.throws(() => assertOwnershipIngestAllowed(REPO_A, env('true', REPO_B)), OwnershipDisabledError) + assert.throws(() => assertOwnershipIngestAllowed(REPO_A, env('false', REPO_A)), OwnershipDisabledError) + // and the thrown error carries no repo id / allowlist content + try { assertOwnershipIngestAllowed(REPO_A, env('true', REPO_B)) } catch (e) { + assert.ok(!e.message.includes(REPO_A)) + assert.ok(!e.message.includes(REPO_B)) + } + assert.doesNotThrow(() => assertOwnershipIngestAllowed(REPO_A, env('true', REPO_A))) +}) + +test('default process env cannot accidentally enable (no ambient ownership env)', () => { + // whatever the ambient env holds, an unlisted repo is still refused + assert.equal(ownershipIngestAllowed('cccccccc-1111-2222-3333-444444444444', {}), false) +}) diff --git a/test/ownership-history-reducer.test.mjs b/test/ownership-history-reducer.test.mjs new file mode 100644 index 0000000..1d6fb6f --- /dev/null +++ b/test/ownership-history-reducer.test.mjs @@ -0,0 +1,289 @@ +// Phase 1C-A monthly history reducer contract — UTC grouping, touch +// semantics, pre-fold human counts, Top-25 + Other, unlinked semantics, +// durable-identity minimization, numeric stability, privacy output shape. + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const { + TOP_LINKED_PER_PERIOD, + monthStartUtc, + shareOf, + reduceHistoryToMonthly, +} = await import('../lib/ownership/history-reducer.mjs') +const { contributorKey, OwnershipIdentityError } = await import('../lib/ownership/contributors.mjs') + +const REPO = '11111111-2222-3333-4444-555555555555' +const SCOPE = { scope_type: 'dir', display_path: 'src/api', depth: 2, scope_path_hash: 'h' } + +// history nodes use the production query shape: committedDate + user {id login} +const node = (iso, user = null) => ({ committedDate: iso, author: { user } }) +const user = (id, login = `u-${id}`) => ({ id, login }) + +/* ── month grouping ──────────────────────────────────────────────────── */ + +test('monthStartUtc is always the first UTC instant of the month', () => { + assert.equal(monthStartUtc('2026-09-14T23:59:59.000Z'), '2026-09-01T00:00:00.000Z') + assert.equal(monthStartUtc('2026-09-01T00:00:00.000Z'), '2026-09-01T00:00:00.000Z') + // a UTC+/-offset input still lands on its UTC month — never local time + assert.equal(monthStartUtc('2026-08-31T23:30:00-02:00'), '2026-09-01T00:00:00.000Z') + assert.equal(monthStartUtc('2026-09-01T01:30:00+05:00'), '2026-08-01T00:00:00.000Z') + assert.throws(() => monthStartUtc('not-a-date'), TypeError) +}) + +test('nodes group by UTC calendar month; empty input yields no rows', () => { + const out = reduceHistoryToMonthly({ + repositoryId: REPO, scope: SCOPE, + nodes: [ + node('2026-08-10T10:00:00Z', user('A')), + node('2026-08-20T10:00:00Z', user('A')), + node('2026-09-01T00:00:01Z', user('B')), + ], + }) + assert.equal(out.months.length, 2) + assert.equal(out.months[0].period_start, '2026-08-01T00:00:00.000Z') + assert.equal(out.months[1].period_start, '2026-09-01T00:00:00.000Z') + assert.equal(reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes: [] }).months.length, 0) +}) + +/* ── touch semantics ─────────────────────────────────────────────────── */ + +test('touch counts and shares are node counts, not code volume', () => { + const out = reduceHistoryToMonthly({ + repositoryId: REPO, scope: SCOPE, + nodes: [ + node('2026-09-05T00:00:00Z', user('A')), + node('2026-09-06T00:00:00Z', user('A')), + node('2026-09-07T00:00:00Z', user('B')), + node('2026-09-08T00:00:00Z'), // unlinked + ], + }) + const [m] = out.months + assert.equal(m.totals.total_touch_commits, 4) + const a = m.contributors.find((c) => c.contributor_key === contributorKey(REPO, 'A')) + assert.equal(a.touch_commits, 2) + assert.equal(a.touch_share, 0.5) + assert.equal(a.identity_kind, 'linked') + const u = m.contributors.find((c) => c.identity_kind === 'unlinked') + assert.equal(u.touch_commits, 1) + assert.equal(u.touch_share, 0.25) +}) + +test('scope totals are the authoritative denominators', () => { + const out = reduceHistoryToMonthly({ + repositoryId: REPO, scope: SCOPE, + nodes: [node('2026-09-01T00:00:00Z', user('A')), node('2026-09-02T00:00:00Z'), node('2026-09-03T00:00:00Z')], + }) + const [m] = out.months + assert.equal(m.totals.total_touch_commits, 3) + assert.equal(m.totals.unlinked_touch_share, shareOf(2, 3)) // 0.6667 +}) + +/* ── human count ─────────────────────────────────────────────────────── */ + +test('human_contributor_count is pre-fold and excludes unlinked/other/bots', () => { + const nodes = [] + for (let i = 0; i < 30; i++) nodes.push(node('2026-09-0' + ((i % 9) + 1) + 'T00:00:00Z', user(`H${i}`))) + nodes.push(node('2026-09-01T00:00:00Z', user('B1', 'ci[bot]'))) // bot + nodes.push(node('2026-09-02T00:00:00Z')) // unlinked + const out = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }) + const [m] = out.months + // 30 distinct humans — the count must NOT cap at the Top-25 fold + assert.equal(m.totals.human_contributor_count, 30) + assert.equal(m.totals.total_touch_commits, 32) +}) + +test('>25 linked humans regression: count stays the real transient count', () => { + const nodes = [] + for (let i = 0; i < 40; i++) nodes.push(node('2026-09-05T00:00:00Z', user(`X${i}`))) + const [m] = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }).months + assert.equal(m.totals.human_contributor_count, 40) +}) + +/* ── Top-25 + Other ──────────────────────────────────────────────────── */ + +test('<=25 linked contributors produce no unnecessary Other row', () => { + const nodes = [] + for (let i = 0; i < 10; i++) nodes.push(node('2026-09-0' + ((i % 9) + 1) + 'T00:00:00Z', user(`L${i}`))) + const [m] = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }).months + assert.equal(m.contributors.filter((c) => c.identity_kind === 'aggregate').length, 0) + assert.equal(m.contributors.length, 10) +}) + +test('>25 linked fold deterministically into Top-25 + Other', () => { + const nodes = [] + // 30 contributors: C0..C29 with descending activity 30..1 touches + for (let i = 0; i < 30; i++) { + for (let t = 0; t < 30 - i; t++) { + nodes.push(node(`2026-09-${String((t % 9) + 1).padStart(2, '0')}T00:00:00Z`, user(`C${i}`))) + } + } + const [m] = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }).months + const linked = m.contributors.filter((c) => c.identity_kind === 'linked') + assert.equal(linked.length, TOP_LINKED_PER_PERIOD) + // Top-25 are C0..C24 by touches; C25..C29 fold into other + const expectedKeys = new Set(Array.from({ length: 25 }, (_, i) => contributorKey(REPO, `C${i}`))) + assert.deepEqual(new Set(linked.map((c) => c.contributor_key)), expectedKeys) + const other = m.contributors.find((c) => c.identity_kind === 'aggregate') + assert.equal(other.contributor_key, 'other') + assert.equal(other.github_node_id, undefined, 'aggregate row carries no identity fields') + assert.equal(other.display_login, undefined) + assert.equal(other.is_bot, false) + assert.equal(other.touch_commits, 5 + 4 + 3 + 2 + 1) // C25..C29 + assert.equal(other.touch_share, shareOf(15, m.totals.total_touch_commits)) + assert.equal(m.totals.human_contributor_count, 30, 'count is pre-fold') +}) + +test('unlinked is NEVER folded into Other — they are separate rows', () => { + const nodes = [] + for (let i = 0; i < 30; i++) nodes.push(node('2026-09-05T00:00:00Z', user(`F${i}`))) + nodes.push(node('2026-09-06T00:00:00Z'), node('2026-09-07T00:00:00Z')) + const [m] = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }).months + const other = m.contributors.find((c) => c.identity_kind === 'aggregate') + const unlinked = m.contributors.find((c) => c.identity_kind === 'unlinked') + assert.ok(other && unlinked, 'both buckets exist') + assert.equal(unlinked.touch_commits, 2) + assert.equal(other.touch_commits, 5) // 30 linked − 25 top + assert.equal(unlinked.identity_kind === 'aggregate', false) +}) + +test('ties break deterministically by contributor key', () => { + const nodes = [] + for (let i = 0; i < 30; i++) nodes.push(node('2026-09-05T00:00:00Z', user(`T${i}`))) + const run = () => reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }).months[0] + const first = run() + // 30 equal contributors → exactly 25 represented + Other(5) + assert.equal(first.contributors.filter((c) => c.identity_kind === 'linked').length, 25) + assert.equal(first.contributors.find((c) => c.identity_kind === 'aggregate').touch_commits, 5) + // the cut set is stable across runs — key-ascending order decides + for (let i = 0; i < 5; i++) assert.deepEqual(run(), first) +}) + +/* ── durable identity minimization ───────────────────────────────────── */ + +test('overflow-only identities produce no durable contributor candidate', () => { + const nodes = [] + for (let i = 0; i < 30; i++) { + for (let t = 0; t <= 30 - i; t++) nodes.push(node('2026-09-05T00:00:00Z', user(`D${i}`))) + } + const out = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }) + const candKeys = new Set(out.contributor_candidates.map((c) => c.contributor_key)) + // Top-25 + 'other' (no unlinked in this input) + assert.equal(out.contributor_candidates.length, 26) + assert.ok(candKeys.has('other')) + // overflow identities D25..D29 contributed to human count + Other but got + // no durable identity candidate + for (let i = 25; i < 30; i++) assert.ok(!candKeys.has(contributorKey(REPO, `D${i}`))) + for (const c of out.contributor_candidates) { + if (c.identity_kind === 'linked') { + assert.ok(c.github_node_id && c.display_login, 'linked candidates carry the durable pair') + assert.ok(c.first_seen_at && c.last_seen_at, 'seen bounds tracked for contributor rows') + } else { + assert.equal(c.github_node_id, null) + } + } +}) + +test('a contributor represented in ANY retained month is a durable candidate', () => { + const nodes = [] + // Z touches Sept alone (top that month); 30 others crowd October + nodes.push(node('2026-09-01T00:00:00Z', user('Z'))) + for (let i = 0; i < 30; i++) nodes.push(node('2026-10-01T00:00:00Z', user(`O${i}`))) + const out = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }) + const keys = new Set(out.contributor_candidates.map((c) => c.contributor_key)) + assert.ok(keys.has(contributorKey(REPO, 'Z')), 'individually represented once → candidate') +}) + +/* ── numeric stability ───────────────────────────────────────────────── */ + +test('shares are bounded, finite, and -0-free', () => { + assert.equal(shareOf(1, 3), 0.3333) + assert.equal(shareOf(2, 3), 0.6667) + assert.equal(shareOf(0, 5), 0) + assert.equal(shareOf(1, 0), 0) + assert.equal(shareOf(5, 0), 0, 'zero denominator → 0, never NaN') + assert.equal(shareOf(Number.NaN, 3), 0) + assert.equal(shareOf(1, Number.POSITIVE_INFINITY), 0) + for (const v of [shareOf(1, 3), shareOf(0, 0)]) { + assert.ok(v >= 0 && v <= 1 && !Object.is(v, -0) && Number.isFinite(v)) + } +}) + +test('rounded shares are not force-summed to 1 — totals stay authoritative', () => { + // 3 equal contributors → 0.3333 each, sum 0.9999 — no mutation to "fix" it + const nodes = [node('2026-09-01T00:00:00Z', user('a')), node('2026-09-02T00:00:00Z', user('b')), node('2026-09-03T00:00:00Z', user('c'))] + const [m] = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }).months + const sum = m.contributors.reduce((n, c) => n + c.touch_share, 0) + assert.ok(Math.abs(sum - 0.9999) < 1e-9, `sum ${sum} — shares must not be mutated to sum exactly 1`) +}) + +/* ── privacy output shape ────────────────────────────────────────────── */ + +test('reducer output carries no raw nodes, SHAs, paths, emails, or messages', () => { + const out = reduceHistoryToMonthly({ + repositoryId: REPO, scope: SCOPE, + nodes: [ + { committedDate: '2026-09-01T00:00:00Z', oid: 'deadbeef1234', author: { user: user('A', 'alice') }, sneaky: 'payload' }, + node('2026-09-02T00:00:00Z'), + ], + }) + const serialized = JSON.stringify(out) + for (const bad of ['deadbeef', 'sneaky', 'oid', 'email', 'message', 'full_name', 'committedDate']) { + assert.ok(!serialized.includes(bad), `output leaked ${bad}`) + } + // allowed: period keys, classification, counts, shares, candidate identity + const monthKeys = Object.keys(out.months[0].totals).sort() + assert.deepEqual(monthKeys, ['human_contributor_count', 'total_touch_commits', 'unlinked_touch_share']) +}) + +test('contributor rows carry only the contracted fields', () => { + const out = reduceHistoryToMonthly({ + repositoryId: REPO, scope: SCOPE, + nodes: [node('2026-09-01T00:00:00Z', user('A', 'alice')), node('2026-09-02T00:00:00Z')], + }) + for (const c of out.months[0].contributors) { + assert.deepEqual(Object.keys(c).sort(), + ['contributor_key', 'identity_kind', 'is_bot', 'touch_commits', 'touch_share']) + } +}) + +/* ── input hardening ─────────────────────────────────────────────────── */ + +test('malformed nodes fail loudly — no silent garbage metrics', () => { + assert.throws(() => reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes: [{}] }), TypeError) + assert.throws(() => reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes: [node('bogus')] }), TypeError) +}) + +test('malformed linked identity propagates OwnershipIdentityError — no partial result', () => { + // a present-but-broken user object is a data-contract violation mid-stream: + // the reducer must throw, never emit months/candidates for the good nodes + const nodes = [ + node('2026-09-01T00:00:00Z', user('A')), + node('2026-09-02T00:00:00Z', { id: 'N_broken' }), // missing login + node('2026-09-03T00:00:00Z', user('B')), + ] + assert.throws( + () => reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }), + OwnershipIdentityError, + ) + try { + reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes }) + } catch (e) { + for (const leak of ['N_broken', 'N_A', REPO]) { + assert.ok(!e.message.includes(leak), `error leaked ${leak}`) + } + } +}) + +test('null/undefined/missing author.user → unlinked; non-null malformed throws', () => { + const unlinkedNodes = [ + { committedDate: '2026-09-01T00:00:00Z' }, // no author + { committedDate: '2026-09-02T00:00:00Z', author: null }, // null author + { committedDate: '2026-09-03T00:00:00Z', author: { user: null } },// null user + node('2026-09-04T00:00:00Z'), // user undefined via helper + ] + const [m] = reduceHistoryToMonthly({ repositoryId: REPO, scope: SCOPE, nodes: unlinkedNodes }).months + assert.equal(m.totals.unlinked_touch_share, 1) + const u = m.contributors.find((c) => c.identity_kind === 'unlinked') + assert.equal(u.touch_commits, 4) +}) diff --git a/test/ownership-queries.test.mjs b/test/ownership-queries.test.mjs new file mode 100644 index 0000000..fd15d94 --- /dev/null +++ b/test/ownership-queries.test.mjs @@ -0,0 +1,120 @@ +// Phase 1C-A production query contract — the metadata-only audit applies to +// the query STRINGS, not just runtime discipline. A field added to a builder +// later fails here. + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const { + FORBIDDEN_OWNERSHIP_FIELD_RE, + BLAME_BATCH_LIMIT, + historyPathQuery, + batchedBlameQuery, + assertOwnershipQuerySafe, +} = await import('../lib/ownership/queries.mjs') + +const ALL_QUERIES = { + history: historyPathQuery(), + blame50: batchedBlameQuery(Array.from({ length: 50 }, (_, i) => `p${i}`)), +} + +/* ── forbidden-field audit ───────────────────────────────────────────── */ + +test('every production query is metadata-only', () => { + for (const [name, q] of Object.entries(ALL_QUERIES)) { + assert.doesNotMatch(q, FORBIDDEN_OWNERSHIP_FIELD_RE, `${name} selects a forbidden field`) + assert.doesNotThrow(() => assertOwnershipQuerySafe(q), name) + } +}) + +test('forbidden classes: source, content, message, email, stats are absent', () => { + for (const bad of ['text', 'byteSize', 'patch', 'diff', 'message', + 'messageHeadline', 'messageBody', 'email', 'signature', 'committer', + 'files', 'changedFiles', 'additions', 'deletions', 'databaseId', + 'isTruncated', 'parents']) { + for (const [name, q] of Object.entries(ALL_QUERIES)) { + assert.doesNotMatch(q, new RegExp(`\\b${bad}\\b`), `${name} selects ${bad}`) + } + } +}) + +test('no Blob/object-content selection can appear', () => { + for (const [name, q] of Object.entries(ALL_QUERIES)) { + assert.doesNotMatch(q, /\.\.\.\s*on\s+Blob/i, `${name} reaches Blob content`) + assert.doesNotMatch(q, /Blob\b/, `${name} references Blob`) + } +}) + +test('identity selection is exactly user { id login } — no name/email creep', () => { + for (const [name, q] of Object.entries(ALL_QUERIES)) { + const blocks = q.match(/user\s*\{[^}]*\}/g) ?? [] + assert.ok(blocks.length > 0, `${name} must select user identity`) + for (const b of blocks) assert.match(b, /^user\s*\{\s*id\s+login\s*\}$/) + } +}) + +/* ── history(path:) shape ────────────────────────────────────────────── */ + +test('history query selects only the reducer contract fields', () => { + const q = historyPathQuery() + assert.match(q, /history\(first: 100, path: \$path, after: \$cursor\)/) + assert.match(q, /pageInfo\s*\{\s*hasNextPage\s+endCursor\s*\}/) + assert.match(q, /committedDate/) + assert.match(q, /author\s*\{\s*user\s*\{\s*id\s+login\s*\}\s*\}/) + assert.match(q, /rateLimit\s*\{\s*cost\s+remaining\s+resetAt\s*\}/) + // oid is deliberately NOT selected — the Phase-1C reducer does not need it + assert.doesNotMatch(q, /\boid\b/) + // no whole-commit stats — measured to be repo-wide, never path metrics + assert.doesNotMatch(q, /\b(additions|deletions|changedFiles)\b/) +}) + +/* ── batched blame shape ─────────────────────────────────────────────── */ + +test('batched blame is capped at the measured 50-alias ceiling', () => { + assert.equal(BLAME_BATCH_LIMIT, 50) + assert.throws(() => batchedBlameQuery(Array(51).fill('x')), RangeError) + assert.throws(() => batchedBlameQuery([]), RangeError) + const q = batchedBlameQuery(['a', 'b']) + assert.match(q, /b0: blame\(path: \$p0\)/) + assert.match(q, /b1: blame\(path: \$p1\)/) + assert.match(q, /rateLimit\s*\{/) +}) + +/* ── audit function itself ───────────────────────────────────────────── */ + +test('assertOwnershipQuerySafe rejects widened selections', () => { + const good = 'nodes { committedDate author { user { id login } } }' + assert.doesNotThrow(() => assertOwnershipQuerySafe(good)) + for (const bad of [ + 'nodes { committedDate message }', + 'nodes { committedDate author { user { id login name } } }', + 'nodes { committedDate author { user { id login email } } }', + 'object { ... on Blob { text } }', + 'commit { oid additions deletions }', + ]) { + assert.throws(() => assertOwnershipQuerySafe(bad), /forbidden|non-contract/, bad) + } +}) + +test('no unmeasured GraphQL tree route exists in the production family', async () => { + const mod = await import('../lib/ownership/queries.mjs') + assert.equal(mod.treeEntriesQuery, undefined, + 'tree enumeration is measured only via the REST recursive git tree — ' + + 'a GraphQL entries route may not be introduced unmeasured') +}) + +test('runtime code never imports the spike query module', async () => { + const { readdirSync, readFileSync } = await import('node:fs') + const { join, dirname, resolve } = await import('node:path') + const { fileURLToPath } = await import('node:url') + const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + for (const dir of ['lib', 'api', 'src']) { + for (const f of readdirSync(join(ROOT, dir), { recursive: true })) { + const rel = String(f).replaceAll('\\', '/') + if (!/\.(mjs|ts|tsx|js|jsx)$/.test(rel)) continue + const text = readFileSync(join(ROOT, dir, rel), 'utf8') + assert.doesNotMatch(text, /(?:from|import)\s*['"(][^'"]*spikes\//, + `${dir}/${rel} must not import the spike module`) + } + } +}) diff --git a/test/ownership-schema.test.mjs b/test/ownership-schema.test.mjs index 0ded4c6..d61ff35 100644 --- a/test/ownership-schema.test.mjs +++ b/test/ownership-schema.test.mjs @@ -229,33 +229,101 @@ test('dash_ownership_* RPCs are STABLE SECURITY INVOKER, tenant-scoped, serve-ga // feature's concrete identifiers instead of the bare word. const OWNERSHIP_IDENTIFIERS = /ownership_scopes|ownership_contributors|ownership_snapshots|ownership_sync_state|dash_ownership|ownership_sync|identity_kind|contributor_key|scope_path_hash/i -test('no ownership ingestion or runtime wiring exists in lib/api/src', () => { +// Phase 1C-A: the pure ingestion core may now exist under lib/ownership/, +// but it must remain unwired — no caller, no DB write, no runtime import. +const OWNERSHIP_CORE_DIR = 'lib/ownership/' +const isOwnershipCore = (rel) => rel.startsWith(OWNERSHIP_CORE_DIR) + +function sourceFiles(dir) { + return readdirSync(path.join(ROOT, dir), { recursive: true }) + .filter((f) => /\.(mjs|ts|tsx|js|jsx)$/.test(String(f))) + .map((f) => `${dir}/${String(f).replaceAll('\\', '/')}`) +} + +test('ownership core stays inside lib/ownership — nothing else references it', () => { for (const dir of ['lib', 'api', 'src']) { - const files = readdirSync(path.join(ROOT, dir), { recursive: true }) - .filter((f) => /\.(mjs|ts|tsx|js|jsx)$/.test(String(f))) - for (const f of files) { - const rel = `${dir}/${String(f).replaceAll('\\', '/')}` + for (const rel of sourceFiles(dir)) { + if (isOwnershipCore(rel)) continue const text = src(rel) assert.doesNotMatch(text, OWNERSHIP_IDENTIFIERS, `${rel} references ownership tables/RPCs — ingestion must stay dormant`) assert.doesNotMatch(text, /blame\(path|history\(path/i, `${rel} must not issue blame/history(path:) GraphQL calls`) + assert.doesNotMatch(text, /from\s+['"][^'"]*ownership\//i, + `${rel} must not import the ownership core`) } } }) -test('no ownership feature flag, env var, cron, or webhook wiring', () => { - assert.doesNotMatch(src('vercel.json'), /OWNERSHIP/i) - assert.doesNotMatch(src('.env.example'), /OWNERSHIP/i) - for (const dir of ['lib', 'api']) { - const files = readdirSync(path.join(ROOT, dir), { recursive: true }) - .filter((f) => /\.(mjs|ts|tsx|js|jsx)$/.test(String(f))) - for (const f of files) { - const rel = `${dir}/${String(f).replaceAll('\\', '/')}` +test('ownership core is pure — no db, octokit, supabase, fs, or runner imports', () => { + for (const rel of sourceFiles('lib').filter(isOwnershipCore)) { + const text = src(rel) + for (const m of text.matchAll(/(?:import|from|require)\s*\(?\s*['"]([^'"]+)['"]/g)) { + const spec = m[1] + assert.ok( + spec.startsWith('node:') || spec.startsWith('./') || spec.startsWith('../'), + `${rel} imports external module ${spec} — the core must stay dependency-free`, + ) + assert.doesNotMatch(spec, /db|github|sync|octokit|supabase|analytics|sessions|require-user/i, + `${rel} must not import runtime infrastructure`) + } + assert.doesNotMatch(text, /process\.env\.(?!OWNERSHIP_INGEST_ENABLED|OWNERSHIP_CANARY_REPOSITORY_IDS)[A-Z_]+/, + `${rel} may only read the two documented gate env vars`) + assert.doesNotMatch(text, /console\.(log|info|warn|error|debug)/, + `${rel} must not console-log — paths/logins/values never hit logs`) + assert.doesNotMatch(text, /securityEvent|posthog|analytics\(/i, + `${rel} must not emit telemetry in this tranche`) + } +}) + +test('no ownership table write exists anywhere in lib/api/src', () => { + // supabase-table refs and SQL write verbs aimed at ownership tables; + // .insert(/.upsert( are PostgREST write verbs — .update(/.delete( are too + // ambiguous with crypto/Map APIs to ban by name alone + const writerRe = /from\s*\(\s*['"`]ownership_|insert\s+into\s+ownership_|update\s+ownership_|delete\s+from\s+ownership_|\.insert\s*\(|\.upsert\s*\(/i + for (const dir of ['lib', 'api', 'src']) { + for (const rel of sourceFiles(dir)) { + if (isOwnershipCore(rel)) continue + const text = src(rel) + for (const tbl of ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']) { + assert.ok(!text.includes(tbl), `${rel} references ${tbl} — writes are a later tranche`) + } + } + } + // the pure core itself must not construct persistence calls either + for (const rel of sourceFiles('lib').filter(isOwnershipCore)) { + const text = src(rel) + assert.doesNotMatch(text, writerRe, `${rel} contains a persistence-shaped call`) + } +}) + +test('gate env vars exist only in the gate module and .env.example docs', () => { + const GATE_RE = /OWNERSHIP_INGEST_ENABLED|OWNERSHIP_CANARY_REPOSITORY_IDS/ + for (const dir of ['lib', 'api', 'src']) { + for (const rel of sourceFiles(dir)) { const text = src(rel) - assert.doesNotMatch(text, /OWNERSHIP_ENABLED|dash_ownership/i, `${rel} must not wire ownership`) + if (rel === 'lib/ownership/gate.mjs') continue + assert.doesNotMatch(text, GATE_RE, `${rel} must not read ownership env directly`) } } + assert.doesNotMatch(src('vercel.json'), /OWNERSHIP/i) + // .env.example may document the gate, but must ship the disabled value + const envExample = src('.env.example') + assert.match(envExample, /OWNERSHIP_INGEST_ENABLED=false/) + assert.doesNotMatch(envExample, /OWNERSHIP_INGEST_ENABLED=true/) +}) + +test('no ownership feature flag, cron, webhook, or dashboard wiring', () => { + for (const f of ['lib/sync.mjs', 'api/sync.mjs', 'api/sync-range.mjs', + 'api/cron/sync.mjs', 'api/webhooks/github.mjs', 'api/dashboard.mjs']) { + const text = src(f) + assert.doesNotMatch(text, OWNERSHIP_IDENTIFIERS, + `${f} must not wire ownership ingestion`) + assert.doesNotMatch(text, /blame\(path|history\(path/i, + `${f} must not issue blame/history(path:) calls`) + assert.doesNotMatch(text, /from\s+['"][^'"]*ownership\//i, + `${f} must not import the ownership core`) + } }) test('live SecurityPrivacyPage stays unamended until ownership collects', () => { diff --git a/test/ownership-scopes.test.mjs b/test/ownership-scopes.test.mjs new file mode 100644 index 0000000..83f0206 --- /dev/null +++ b/test/ownership-scopes.test.mjs @@ -0,0 +1,254 @@ +// Phase 1C-A scope/tree contract — normalization, exclusion classes, +// file-path-never-persisted, deterministic caps, and hash semantics. + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +const { + MAX_DEPTH, + MAX_DISPLAY_PATH_LENGTH, + MAX_DIR_SCOPES, + REPO_SCOPE_SENTINEL, + repoScopePathHash, + dirScopePathHash, + normalizePath, + pathDepth, + isValidDirScopePath, + isExcludedPath, + isBinaryBlobPath, + repoScopeCandidate, + deriveScopeCandidates, +} = await import('../lib/ownership/scopes.mjs') + +/* ── normalization ───────────────────────────────────────────────────── */ + +test('paths normalize to canonical form', () => { + assert.equal(normalizePath('src/api/routes'), 'src/api/routes') + assert.equal(normalizePath('/src/api/'), 'src/api') + assert.equal(normalizePath('src//api///routes'), 'src/api/routes') + assert.equal(normalizePath('src\\api\\routes'), 'src/api/routes') + assert.equal(normalizePath('a'), 'a') + assert.equal(pathDepth('a/b/c/d'), 4) +}) + +test('malformed/traversal/empty paths are rejected', () => { + for (const bad of ['', '/', '///', ' ', null, undefined, 42, {}, + 'a/./b', 'a/../b', '..', '.', '../x', 'a/b/../c', + 'a\0b', 'ab']) { + assert.equal(normalizePath(bad), null, `rejected ${JSON.stringify(bad)}`) + } +}) + +test('dir scope envelope: depth <= 4, length <= 200', () => { + assert.equal(isValidDirScopePath('a/b/c/d'), true) + assert.equal(isValidDirScopePath('a/b/c/d/e'), false, 'depth 5') + assert.equal(isValidDirScopePath('x'.repeat(200)), true) + assert.equal(isValidDirScopePath('x'.repeat(201)), false, '>200') + assert.equal(isValidDirScopePath(null), false) +}) + +/* ── hashes ──────────────────────────────────────────────────────────── */ + +test('scope hashes are deterministic, domain-separated, and sentinel-free of identity', () => { + const a = dirScopePathHash('src/api') + assert.equal(a, dirScopePathHash('src/api'), 'deterministic') + assert.match(a, /^[0-9a-f]{64}$/) + const r = repoScopePathHash() + assert.equal(r, repoScopePathHash(), 'repo hash deterministic') + assert.match(r, /^[0-9a-f]{64}$/) + assert.notEqual(a, r, 'dir and repo hash namespaces are separated') + assert.ok(!REPO_SCOPE_SENTINEL.includes('github'), 'sentinel carries no provider identity') + // not rename-stable by design — a moved directory is a new scope identity + assert.notEqual(dirScopePathHash('old/dir'), dirScopePathHash('new/dir')) +}) + +/* ── exclusion classes ───────────────────────────────────────────────── */ + +test('vendor/generated/build/dependency segments are excluded at any depth', () => { + for (const p of ['node_modules', 'src/node_modules/x', 'a/vendor/b', + 'packages/web/dist', 'lib/build', 'x/out/y', 'srv/target', 'app/coverage', + 'api/generated', 'x/__generated__', 'a/Pods/b', 'e/.venv/lib', + 'repo/.git/objects']) { + assert.equal(isExcludedPath(p), true, p) + } +}) + +test('legitimate source directories are NOT excluded', () => { + for (const p of ['src', 'src/api', 'app/components', 'lib', 'packages/core', + 'internal/server', 'docs', 'scripts', 'bin', 'test', 'web/public', + 'generator', 'vendorsmith', 'distributor', 'outlines', 'builder']) { + assert.equal(isExcludedPath(p), false, p) + } +}) + +/* ── enumeration → candidates ────────────────────────────────────────── */ + +test('repo scope is always present with fixed shape', () => { + const { scopes } = deriveScopeCandidates([]) + assert.deepEqual(scopes[0], { + scope_type: 'repo', display_path: null, depth: 0, + scope_path_hash: repoScopePathHash(), + }) + assert.equal(scopes.length, 1) +}) + +test('file paths NEVER become scope candidates', () => { + const entries = [ + { path: 'src/api/index.ts', type: 'blob' }, + { path: 'src/api/routes/user.ts', type: 'blob' }, + { path: 'README.md', type: 'blob' }, + { path: 'src/api/routes', type: 'tree' }, + { path: 'src/api', type: 'tree' }, + { path: 'src', type: 'tree' }, + ] + const { scopes, overflow } = deriveScopeCandidates(entries) + const paths = scopes.map((s) => s.display_path).filter(Boolean) + for (const p of paths) { + for (const e of entries.filter((e) => e.type === 'blob')) { + assert.notEqual(p, e.path, `file path ${e.path} became a scope`) + } + } + assert.deepEqual(new Set(paths), new Set(['src', 'src/api', 'src/api/routes'])) + assert.equal(overflow.length, 0) + // no candidate ever carries a 'file' scope type + for (const s of scopes) assert.ok(s.scope_type === 'repo' || s.scope_type === 'dir') +}) + +test('blob ancestors are inferred when the tree is a flat file listing', () => { + const { scopes } = deriveScopeCandidates([ + { path: 'a/b/file1.ts', type: 'blob' }, + { path: 'a/c/file2.ts', type: 'blob' }, + ]) + const paths = scopes.map((s) => s.display_path) + assert.ok(paths.includes('a') && paths.includes('a/b') && paths.includes('a/c')) +}) + +test('submodule gitlinks are excluded by entry type, never by name', () => { + const { scopes } = deriveScopeCandidates([ + { path: 'third_party/repo', type: 'commit' }, // submodule — excluded + { path: 'third_party/normal/file.ts', type: 'blob' }, // its sibling dir survives + ]) + const paths = scopes.map((s) => s.display_path) + assert.ok(!paths.includes('third_party/repo'), 'submodule path must not be a scope') + assert.ok(paths.includes('third_party') && paths.includes('third_party/normal')) +}) + +test('excluded-class paths never appear in candidates OR overflow', () => { + const entries = [ + { path: 'src/ok.ts', type: 'blob' }, + { path: 'node_modules/pkg/index.js', type: 'blob' }, + { path: 'a/node_modules/deep/x.js', type: 'blob' }, + { path: 'dist/bundle.js', type: 'blob' }, + { path: 'api/generated/client.ts', type: 'blob' }, + ] + const { scopes, overflow } = deriveScopeCandidates(entries) + const all = [...scopes, ...overflow].map((s) => s.display_path).filter(Boolean) + for (const p of all) assert.equal(isExcludedPath(p), false, `${p} leaked`) + // 'a' and 'api' contain ONLY excluded content — dirs do not exist merely + // because excluded blobs appeared in the tree + assert.deepEqual(all, ['src']) +}) + +test('>500 otherwise-valid dirs resolve deterministically (depth, then path)', () => { + const entries = [] + // 4 shallow dirs each with 200 children = 800 otherwise-valid dir scopes + for (const top of ['a', 'b', 'c', 'd']) { + for (let i = 0; i < 200; i++) { + entries.push({ path: `${top}/d${String(i).padStart(3, '0')}/f.ts`, type: 'blob' }) + } + } + const { scopes, overflow } = deriveScopeCandidates(entries) + const dirs = scopes.slice(1) + assert.equal(dirs.length, MAX_DIR_SCOPES) + assert.ok(overflow.length > 0) + // ordering: depth 1 first (a,b,c,d), then depth 2 lexical — never input order + assert.deepEqual(dirs.slice(0, 4).map((s) => s.display_path), ['a', 'b', 'c', 'd']) + assert.equal(dirs[4].display_path, 'a/d000') + // determinism: shuffled input produces the same selection + const shuffled = [...entries].reverse() + const again = deriveScopeCandidates(shuffled) + assert.deepEqual(again.scopes, scopes) + assert.deepEqual(again.overflow, overflow) + // overflow entries are valid-but-omitted dirs — persistence may later mark + // them 'cardinality_cap'; nothing here fabricates exclusion rows + for (const o of overflow) { + assert.equal(o.scope_type, 'dir') + assert.equal(isExcludedPath(o.display_path), false) + } +}) + +test('binary-only directories produce no scope; eligible blobs derive dirs', () => { + const { scopes, overflow } = deriveScopeCandidates([ + { path: 'assets/logo.png', type: 'blob' }, + { path: 'assets/icons/close.svg', type: 'blob' }, // svg is NOT binary + { path: 'media/intro.mp4', type: 'blob' }, + { path: 'fonts/main.woff2', type: 'blob' }, + { path: 'docs/spec.pdf', type: 'blob' }, + { path: 'pkg/release.zip', type: 'blob' }, + { path: 'bin/app.exe', type: 'blob' }, + { path: 'deep/a/b/c/d.bin', type: 'blob' }, + ]) + const paths = [...scopes, ...overflow].map((s) => s.display_path).filter(Boolean) + // every listed dir is binary-only EXCEPT 'assets' (has close.svg) — 'bin' + // is only an eligible dir name, but its blob is binary so nothing results + assert.deepEqual(paths, ['assets', 'assets/icons']) +}) + +test('mixed source + binary directory stays a scope; binary file never appears', () => { + const { scopes } = deriveScopeCandidates([ + { path: 'src/widget.ts', type: 'blob' }, + { path: 'src/widget.png', type: 'blob' }, + { path: 'src/readme.pdf', type: 'blob' }, + ]) + const paths = scopes.map((s) => s.display_path) + assert.ok(paths.includes('src')) + for (const e of ['src/widget.png', 'src/readme.pdf', 'src/widget.ts']) { + assert.ok(!paths.includes(e), `${e} is a file — never a scope`) + } +}) + +test('binary classifier is extension-conservative — text/source stays eligible', () => { + for (const p of ['a/logo.png', 'x/favicon.ICO', 'm/song.FlAc', 'z/lib.o', + 'f/font.woff2', 'd/paper.pdf', 'v/clip.webm', 'r/pack.tar.gz']) { + assert.equal(isBinaryBlobPath(p), true, p) + } + for (const p of ['a/logo.svg', 'x/readme.md', 's/main.ts', 'c/config.yaml', + 'm/bundle.min.js', 'l/package-lock.json', 'd/data.csv', 'a/LICENSE', + 'x/file', 'a/.gitignore', 'src/app.exe.ts']) { + assert.equal(isBinaryBlobPath(p), false, p) + } +}) + +test('tree entries alone never create scopes — dirs derive from eligible blobs', () => { + const { scopes } = deriveScopeCandidates([ + { path: 'a/b/c', type: 'tree' }, + { path: 'a/b', type: 'tree' }, + { path: 'a', type: 'tree' }, + ]) + assert.equal(scopes.length, 1, 'only the repo scope — trees imply nothing without eligible blobs') + assert.equal(scopes[0].scope_type, 'repo') +}) + +test('enumeration order never affects the result', () => { + const entries = [ + { path: 'z/deep/file.ts', type: 'blob' }, + { path: 'a/img.png', type: 'blob' }, + { path: 'm/index.ts', type: 'blob' }, + { path: 'a/logo.svg', type: 'blob' }, + ] + const forward = deriveScopeCandidates(entries) + const reverse = deriveScopeCandidates([...entries].reverse()) + assert.deepEqual(forward, reverse) +}) + +test('candidate shape maps onto ownership_scopes columns only', () => { + const { scopes } = deriveScopeCandidates([{ path: 'x/y/f.ts', type: 'blob' }]) + for (const s of scopes) { + assert.deepEqual(Object.keys(s).sort(), + ['depth', 'display_path', 'scope_path_hash', 'scope_type']) + if (s.scope_type === 'dir') { + assert.equal(s.depth, s.display_path.split('/').length) + assert.equal(s.scope_path_hash, dirScopePathHash(s.display_path)) + } + } +}) diff --git a/test/supply-chain.test.mjs b/test/supply-chain.test.mjs index 14d275c..77811bc 100644 --- a/test/supply-chain.test.mjs +++ b/test/supply-chain.test.mjs @@ -264,8 +264,10 @@ test('.env.example contains placeholder values only', () => { for (const line of ex.split('\n')) { const m = line.match(/^([A-Z_]+)=(.+)$/); if (!m) continue; + // 'false' is an allowed placeholder for hard-off feature gates — never + // 'true': an enabled-by-default example would defeat the gate's purpose assert.ok( - /^(https?:\/\/[\w.-]+|[\w.-]+\.supabase\.com|\d+)$/.test(m[2]), + /^(https?:\/\/[\w.-]+|[\w.-]+\.supabase\.com|\d+|false)$/.test(m[2]), `.env.example ${m[1]} holds a non-placeholder value`, ); }