|
| 1 | +import { describe, expect, it } from 'bun:test' |
| 2 | + |
| 3 | +import { |
| 4 | + REPO_SNAPSHOT_FIELDS, |
| 5 | + toRepoSnapshot, |
| 6 | + type RepoSnapshot, |
| 7 | +} from '../file' |
| 8 | + |
| 9 | +import type { ProjectFileContext } from '../file' |
| 10 | + |
| 11 | +type GitChanges = ProjectFileContext['gitChanges'] |
| 12 | + |
| 13 | +/** A snapshot with every aggregate field set, plus the legacy fields an older |
| 14 | + * client still populates. The legacy ones are the hazard this suite exists |
| 15 | + * for: they carry raw patch content. */ |
| 16 | +const fullGitChanges = (): GitChanges => ({ |
| 17 | + gitAvailable: true, |
| 18 | + branch: 'feature/secret-project-name', |
| 19 | + changedFiles: ['src/internal/pricing.ts', 'docs/acquisition-memo.md'], |
| 20 | + changedFileCount: 2, |
| 21 | + changedFileScanTruncated: false, |
| 22 | + repositoryVisibility: 'private', |
| 23 | + commitCount: 4471, |
| 24 | + historyIsShallow: false, |
| 25 | + historyScanTruncated: false, |
| 26 | + commitDatePercentiles: { |
| 27 | + p0: '2019-03-02', |
| 28 | + p25: '2021-07-14', |
| 29 | + p50: '2023-01-09', |
| 30 | + p75: '2024-11-30', |
| 31 | + p100: '2026-08-24', |
| 32 | + }, |
| 33 | + mergedPullRequestCount: 812, |
| 34 | + humanContributorCount: 34, |
| 35 | + botContributorCount: 2, |
| 36 | + contributorCount: 36, |
| 37 | + fileCount: 8123, |
| 38 | + fileCountIsLowerBound: false, |
| 39 | + testFileCount: 611, |
| 40 | + status: 'On branch feature/secret-project-name\nChanges not staged', |
| 41 | + diff: 'diff --git a/src/internal/pricing.ts b/src/internal/pricing.ts\n+const MARGIN = 0.42', |
| 42 | + diffCached: 'diff --git a/docs/acquisition-memo.md b/docs/acquisition-memo.md\n+Target: $4M', |
| 43 | + lastCommitMessages: 'Raise the enterprise floor before the Q3 renewal', |
| 44 | +}) |
| 45 | + |
| 46 | +describe('toRepoSnapshot', () => { |
| 47 | + it('never emits patch content, paths, or branch names', () => { |
| 48 | + const snapshot = toRepoSnapshot(fullGitChanges()) |
| 49 | + const serialized = JSON.stringify(snapshot) |
| 50 | + |
| 51 | + // The four legacy fields hold raw repository source. A spread instead of |
| 52 | + // the allowlist would put all of them in the warehouse. |
| 53 | + for (const forbidden of [ |
| 54 | + 'status', |
| 55 | + 'diff', |
| 56 | + 'diffCached', |
| 57 | + 'lastCommitMessages', |
| 58 | + // Names and paths are out of contract for an aggregate snapshot. |
| 59 | + 'branch', |
| 60 | + 'changedFiles', |
| 61 | + ]) { |
| 62 | + expect(snapshot).not.toHaveProperty(forbidden) |
| 63 | + } |
| 64 | + |
| 65 | + // Belt and braces: assert on the serialized bytes too, so a future field |
| 66 | + // that merely *embeds* one of these values also trips the test. |
| 67 | + expect(serialized).not.toContain('diff --git') |
| 68 | + expect(serialized).not.toContain('MARGIN') |
| 69 | + expect(serialized).not.toContain('acquisition-memo') |
| 70 | + expect(serialized).not.toContain('secret-project-name') |
| 71 | + expect(serialized).not.toContain('Raise the enterprise floor') |
| 72 | + }) |
| 73 | + |
| 74 | + it('keeps every aggregate the scoring job reads', () => { |
| 75 | + const snapshot = toRepoSnapshot(fullGitChanges()) |
| 76 | + |
| 77 | + expect(snapshot).toMatchObject({ |
| 78 | + gitAvailable: true, |
| 79 | + repositoryVisibility: 'private', |
| 80 | + commitCount: 4471, |
| 81 | + mergedPullRequestCount: 812, |
| 82 | + humanContributorCount: 34, |
| 83 | + botContributorCount: 2, |
| 84 | + fileCount: 8123, |
| 85 | + testFileCount: 611, |
| 86 | + }) |
| 87 | + expect(snapshot?.commitDatePercentiles?.p50).toBe('2023-01-09') |
| 88 | + }) |
| 89 | + |
| 90 | + it('carries the lower-bound flags, which are unrecoverable if dropped', () => { |
| 91 | + const snapshot = toRepoSnapshot({ |
| 92 | + ...fullGitChanges(), |
| 93 | + historyIsShallow: true, |
| 94 | + historyScanTruncated: true, |
| 95 | + fileCountIsLowerBound: true, |
| 96 | + }) |
| 97 | + |
| 98 | + expect(snapshot?.historyIsShallow).toBe(true) |
| 99 | + expect(snapshot?.historyScanTruncated).toBe(true) |
| 100 | + expect(snapshot?.fileCountIsLowerBound).toBe(true) |
| 101 | + }) |
| 102 | + |
| 103 | + it('omits absent fields rather than emitting undefined', () => { |
| 104 | + const snapshot = toRepoSnapshot({ gitAvailable: true, fileCount: 12 }) |
| 105 | + |
| 106 | + expect(Object.keys(snapshot ?? {}).sort()).toEqual([ |
| 107 | + 'fileCount', |
| 108 | + 'gitAvailable', |
| 109 | + ]) |
| 110 | + }) |
| 111 | + |
| 112 | + it('returns undefined when there is nothing to record', () => { |
| 113 | + expect(toRepoSnapshot(undefined)).toBeUndefined() |
| 114 | + // A legacy-only snapshot carries no allowlisted field, so it must produce |
| 115 | + // an absent column rather than a row of nulls that reads as measured zero. |
| 116 | + expect( |
| 117 | + toRepoSnapshot({ status: 'On branch main', diff: 'diff --git a/a b/a' }), |
| 118 | + ).toBeUndefined() |
| 119 | + }) |
| 120 | + |
| 121 | + it('emits false and zero, which are values rather than absences', () => { |
| 122 | + const snapshot = toRepoSnapshot({ |
| 123 | + gitAvailable: false, |
| 124 | + commitCount: 0, |
| 125 | + testFileCount: 0, |
| 126 | + }) |
| 127 | + |
| 128 | + expect(snapshot?.gitAvailable).toBe(false) |
| 129 | + expect(snapshot?.commitCount).toBe(0) |
| 130 | + expect(snapshot?.testFileCount).toBe(0) |
| 131 | + }) |
| 132 | + |
| 133 | + it('has no allowlisted field that can hold free text', () => { |
| 134 | + // Every field in the contract must be a count, a boolean, a bounded enum, |
| 135 | + // or the date-percentile object. A future string field is how a path or a |
| 136 | + // repo name gets back in, so the shape is asserted rather than reviewed. |
| 137 | + const snapshot = toRepoSnapshot(fullGitChanges()) as Record<string, unknown> |
| 138 | + |
| 139 | + for (const key of REPO_SNAPSHOT_FIELDS) { |
| 140 | + const value = snapshot[key] |
| 141 | + if (value === undefined) continue |
| 142 | + if (key === 'repositoryVisibility') { |
| 143 | + expect(['public', 'private', 'internal', 'unknown']).toContain(value) |
| 144 | + continue |
| 145 | + } |
| 146 | + if (key === 'commitDatePercentiles') { |
| 147 | + expect(typeof value).toBe('object') |
| 148 | + continue |
| 149 | + } |
| 150 | + expect(['number', 'boolean']).toContain(typeof value) |
| 151 | + } |
| 152 | + }) |
| 153 | + |
| 154 | + it('is the single source of truth for the field list', () => { |
| 155 | + // The trace writer re-applies this allowlist server-side, because the |
| 156 | + // client-side projection runs in code we do not control at runtime. If the |
| 157 | + // two lists drift, a field added here is silently stripped on arrival — |
| 158 | + // which looks like a producer bug and is not one. |
| 159 | + // |
| 160 | + // Asserted as a literal so adding a field is a visible line in a diff, the |
| 161 | + // same way REPO_SNAPSHOT_FIELDS itself is. |
| 162 | + expect([...REPO_SNAPSHOT_FIELDS].sort()).toEqual([ |
| 163 | + 'botContributorCount', |
| 164 | + 'changedFileCount', |
| 165 | + 'changedFileScanTruncated', |
| 166 | + 'commitCount', |
| 167 | + 'commitDatePercentiles', |
| 168 | + 'contributorCount', |
| 169 | + 'fileCount', |
| 170 | + 'fileCountIsLowerBound', |
| 171 | + 'gitAvailable', |
| 172 | + 'historyIsShallow', |
| 173 | + 'historyScanTruncated', |
| 174 | + 'humanContributorCount', |
| 175 | + 'mergedPullRequestCount', |
| 176 | + 'repositoryVisibility', |
| 177 | + 'testFileCount', |
| 178 | + ]) |
| 179 | + }) |
| 180 | + |
| 181 | + it('exposes a type that matches the runtime allowlist', () => { |
| 182 | + // Compile-time guard: RepoSnapshot must stay derived from the field list, |
| 183 | + // so adding a key to one without the other fails to typecheck. |
| 184 | + const snapshot: RepoSnapshot | undefined = toRepoSnapshot(fullGitChanges()) |
| 185 | + expect(snapshot).toBeDefined() |
| 186 | + }) |
| 187 | +}) |
0 commit comments