pi-jscpd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/CONTRIBUTING.md +144 -0
  3. package/LICENSE +21 -0
  4. package/README.md +231 -0
  5. package/SECURITY.md +93 -0
  6. package/docs/automatic-checkpoint.md +235 -0
  7. package/docs/compatibility.md +119 -0
  8. package/docs/effect-architecture.md +128 -0
  9. package/docs/fallow-coexistence.md +120 -0
  10. package/docs/overlay-interaction.md +347 -0
  11. package/docs/release.md +115 -0
  12. package/package.json +86 -0
  13. package/scripts/check-compatibility.mjs +103 -0
  14. package/skills/jscpd/SKILL.md +90 -0
  15. package/src/acknowledgements.ts +268 -0
  16. package/src/automatic.ts +396 -0
  17. package/src/baseline.ts +400 -0
  18. package/src/capability.ts +569 -0
  19. package/src/changed-files.ts +372 -0
  20. package/src/changed.ts +548 -0
  21. package/src/clone-identity.ts +373 -0
  22. package/src/config.ts +414 -0
  23. package/src/contract.ts +39 -0
  24. package/src/dispatch.ts +90 -0
  25. package/src/effect/clock.ts +10 -0
  26. package/src/effect/errors.ts +311 -0
  27. package/src/effect/filesystem.ts +240 -0
  28. package/src/effect/runtime-boundary.ts +25 -0
  29. package/src/effect/runtime-contract.ts +18 -0
  30. package/src/effect/services.ts +131 -0
  31. package/src/extension.ts +708 -0
  32. package/src/fallow.ts +479 -0
  33. package/src/finding-presentation.ts +73 -0
  34. package/src/index.ts +8 -0
  35. package/src/jscpd-report.ts +819 -0
  36. package/src/jscpd.ts +748 -0
  37. package/src/overlay.ts +1166 -0
  38. package/src/parser.ts +189 -0
  39. package/src/path-utils.ts +44 -0
  40. package/src/presentation.ts +232 -0
  41. package/src/process.ts +425 -0
  42. package/src/registry.ts +102 -0
  43. package/src/scan.ts +441 -0
  44. package/src/scheduler.ts +434 -0
  45. package/src/session-state.ts +229 -0
  46. package/src/status.ts +534 -0
  47. package/src/types.ts +334 -0
  48. package/src/value-utils.ts +14 -0
  49. package/src/verification.ts +220 -0
package/src/types.ts ADDED
@@ -0,0 +1,334 @@
1
+ import type { JscpdCapabilityResult } from "./capability.js";
2
+ import type { JscpdCommand } from "./registry.js";
3
+
4
+ export type { JscpdCommand } from "./registry.js";
5
+
6
+ export interface JscpdCommandInvocation {
7
+ command: JscpdCommand;
8
+ args: readonly string[];
9
+ }
10
+
11
+ export interface JscpdExecutionContext {
12
+ cwd: string;
13
+ signal?: AbortSignal;
14
+ /** Internal TUI request for a bounded cache beyond the configured model/tool display limit. */
15
+ overlayFindingLimit?: number;
16
+ }
17
+
18
+ /** Reporter-independent statistics supplied by jscpd; pi-jscpd does not recalculate them. */
19
+ export interface JscpdStatisticsRow {
20
+ readonly lines: number;
21
+ readonly tokens: number;
22
+ readonly sources: number;
23
+ readonly clones: number;
24
+ readonly duplicatedLines: number;
25
+ readonly duplicatedTokens: number;
26
+ readonly percentage: number;
27
+ readonly percentageTokens: number;
28
+ readonly newDuplicatedLines: number;
29
+ readonly newClones: number;
30
+ }
31
+
32
+ export interface JscpdFormatStatistics extends JscpdStatisticsRow {
33
+ readonly format: string;
34
+ }
35
+
36
+ export interface JscpdScanStatistics {
37
+ readonly total: JscpdStatisticsRow;
38
+ /** Sorted by format so reporter object-key order cannot affect normalized output. */
39
+ readonly formats: readonly JscpdFormatStatistics[];
40
+ }
41
+
42
+ export interface JscpdSourceLocation {
43
+ /** One-based source line. */
44
+ readonly line: number;
45
+ /** Zero-based source column, as emitted by jscpd v5. */
46
+ readonly column: number;
47
+ /** Zero-based source byte offset, named `position` by the JSON reporter. */
48
+ readonly offset: number;
49
+ }
50
+
51
+ export interface JscpdCloneOccurrence {
52
+ /** Canonical project-relative path using `/` separators. */
53
+ readonly path: string;
54
+ readonly start: JscpdSourceLocation;
55
+ readonly end: JscpdSourceLocation;
56
+ }
57
+
58
+ /** The v5 JSON reporter models each clone as one pair rather than a larger clone group. */
59
+ export interface JscpdClonePair {
60
+ readonly format: string;
61
+ readonly lines: number;
62
+ readonly tokens: number;
63
+ readonly occurrences: readonly [JscpdCloneOccurrence, JscpdCloneOccurrence];
64
+ }
65
+
66
+ export interface JscpdScanReport {
67
+ readonly statistics: JscpdScanStatistics;
68
+ /** Deterministically sorted, bounded clone pairs with no source fragments. */
69
+ readonly clonePairs: readonly JscpdClonePair[];
70
+ }
71
+
72
+ export type JscpdReportErrorCode =
73
+ | "malformed-json"
74
+ | "unsupported-reporter"
75
+ | "invalid-top-level"
76
+ | "invalid-duplicates"
77
+ | "invalid-statistics"
78
+ | "invalid-location"
79
+ | "unsafe-path"
80
+ | "limit-exceeded"
81
+ | "duplicate-key"
82
+ | "ambiguous-path"
83
+ | "ambiguous-duplicate";
84
+
85
+ export type JscpdReportDecision<T> =
86
+ | { status: "accepted"; value: T }
87
+ | { status: "no-findings"; value?: T }
88
+ | { status: "rejected"; reason: JscpdReportErrorCode };
89
+
90
+ export type JscpdUnavailableReason =
91
+ | "disabled"
92
+ | "missing-binary"
93
+ | "incompatible-version"
94
+ | "probe-cancelled"
95
+ | "probe-timed-out"
96
+ | "probe-failed";
97
+
98
+ export interface JscpdUnavailableResult {
99
+ status: "unavailable";
100
+ reason: JscpdUnavailableReason;
101
+ message: string;
102
+ capability?: JscpdCapabilityResult;
103
+ }
104
+
105
+ export interface JscpdScanSummary {
106
+ readonly clones: number;
107
+ readonly duplicatedLines: number;
108
+ readonly duplicatedTokens: number;
109
+ readonly lines: number;
110
+ readonly tokens: number;
111
+ readonly sources: number;
112
+ readonly percentage: number;
113
+ readonly percentageTokens: number;
114
+ }
115
+
116
+ export interface JscpdPresentedOccurrence {
117
+ /** A bounded project-relative display path followed by exact line coordinates. */
118
+ readonly path: string;
119
+ readonly startLine: number;
120
+ readonly endLine: number;
121
+ }
122
+
123
+ export interface JscpdPresentedFinding {
124
+ readonly format: string;
125
+ readonly lines: number;
126
+ readonly tokens: number;
127
+ readonly occurrences: readonly [JscpdPresentedOccurrence, JscpdPresentedOccurrence];
128
+ }
129
+
130
+ export interface JscpdChangedOccurrence extends JscpdPresentedOccurrence {
131
+ readonly relation: "new-session" | "existing-match";
132
+ }
133
+
134
+ export interface JscpdChangedFinding {
135
+ readonly format: string;
136
+ readonly lines: number;
137
+ readonly tokens: number;
138
+ readonly occurrences: readonly [JscpdChangedOccurrence, JscpdChangedOccurrence];
139
+ }
140
+
141
+ export interface JscpdChangedResult {
142
+ readonly status: "changed";
143
+ readonly outcome: "findings" | "clean";
144
+ /** False only when the check short-circuited because no session-owned files were tracked. */
145
+ readonly scanPerformed: boolean;
146
+ readonly message: string;
147
+ readonly terminalMessage: string;
148
+ /** Configured-limit findings used by commands, tools, acknowledgements, and model output. */
149
+ readonly findings: readonly JscpdChangedFinding[];
150
+ readonly omittedFindings: number;
151
+ readonly ambiguousFindings: number;
152
+ /** Ephemeral overlay-only data, present only when the TUI adapter explicitly requests it. */
153
+ readonly overlayCache?: JscpdOverlayFindingCache<JscpdChangedFinding>;
154
+ readonly verification?: JscpdVerificationResult;
155
+ }
156
+
157
+ export type JscpdChangedUnavailableReason =
158
+ | "baseline-pending"
159
+ | "baseline-unavailable"
160
+ | "baseline-partial"
161
+ | "baseline-cancelled"
162
+ | "baseline-timed-out"
163
+ | "baseline-failed"
164
+ | "identity-partial";
165
+
166
+ export interface JscpdChangedUnavailableResult {
167
+ readonly status: "changed-unavailable";
168
+ readonly reason: JscpdChangedUnavailableReason;
169
+ readonly message: string;
170
+ }
171
+
172
+ export type JscpdVerificationResult =
173
+ | {
174
+ readonly state: "checkpoint";
175
+ readonly scope: "changed" | "project";
176
+ readonly groups: number;
177
+ readonly message: string;
178
+ }
179
+ | {
180
+ readonly state: "compared";
181
+ readonly scope: "changed" | "project";
182
+ readonly removed: number;
183
+ readonly remaining: number;
184
+ readonly created: number;
185
+ readonly ambiguous: number;
186
+ readonly message: string;
187
+ }
188
+ | {
189
+ readonly state: "unavailable";
190
+ readonly scope: "changed" | "project";
191
+ readonly reason: "identity-partial" | "lifecycle-changed";
192
+ readonly message: string;
193
+ };
194
+
195
+ export interface JscpdOverlayFindingCache<T> {
196
+ readonly findings: readonly T[];
197
+ /** Findings beyond the bounded overlay cache, excluding safely unclassified groups. */
198
+ readonly omittedFindings: number;
199
+ }
200
+
201
+ export interface JscpdCompletedResult {
202
+ readonly status: "completed";
203
+ readonly outcome: "findings" | "clean";
204
+ /** Concise content returned to the model-facing tool. */
205
+ readonly message: string;
206
+ /** Concise content shown by the terminal slash command. */
207
+ readonly terminalMessage: string;
208
+ readonly summary: JscpdScanSummary;
209
+ /** Configured-limit findings used by commands, tools, and model output. */
210
+ readonly findings: readonly JscpdPresentedFinding[];
211
+ readonly omittedFindings: number;
212
+ /** Ephemeral overlay-only data, present only when the TUI adapter explicitly requests it. */
213
+ readonly overlayCache?: JscpdOverlayFindingCache<JscpdPresentedFinding>;
214
+ readonly verification?: JscpdVerificationResult;
215
+ }
216
+
217
+ export type JscpdScanFailureReason =
218
+ | "unsafe-path"
219
+ | "unsupported-path"
220
+ | "scan-cancelled"
221
+ | "scan-timed-out"
222
+ | "process-failed"
223
+ | "missing-report"
224
+ | "malformed-report"
225
+ | "incompatible-report"
226
+ | "invalid-report"
227
+ | "cleanup-failed";
228
+
229
+ export interface JscpdScanFailureResult {
230
+ readonly status: "failed";
231
+ readonly reason: JscpdScanFailureReason;
232
+ readonly message: string;
233
+ }
234
+
235
+ export type JscpdLastCheck =
236
+ | { readonly state: "never" }
237
+ | { readonly state: "clean" }
238
+ | { readonly state: "findings"; readonly clones: number }
239
+ | { readonly state: "cancelled" }
240
+ | {
241
+ readonly state: "failed";
242
+ readonly reason: JscpdScanFailureReason | JscpdUnavailableReason;
243
+ };
244
+
245
+ export interface JscpdStatusResult {
246
+ readonly status: "status";
247
+ readonly message: string;
248
+ readonly terminalMessage: string;
249
+ readonly mode: "enabled" | "disabled";
250
+ readonly modeSource: "configuration" | "session";
251
+ readonly configSource: "defaults" | "project" | "local";
252
+ readonly configSources: readonly ("defaults" | "project" | "local")[];
253
+ readonly configDiagnostics: number;
254
+ readonly capability: JscpdCapabilityResult;
255
+ readonly lastCheck: JscpdLastCheck;
256
+ readonly fallowOverlap?:
257
+ | "absent"
258
+ | "detected"
259
+ | "ambiguous"
260
+ | "explicit-allow"
261
+ | "explicit-on-demand";
262
+ readonly fallowAutomatic?: "allowed" | "on-demand";
263
+ }
264
+
265
+ export interface JscpdControlResult {
266
+ readonly status: "control";
267
+ readonly action: "enabled" | "disabled";
268
+ readonly message: string;
269
+ readonly terminalMessage: string;
270
+ }
271
+
272
+ export interface JscpdHelpResult {
273
+ readonly status: "help";
274
+ readonly message: string;
275
+ readonly terminalMessage: string;
276
+ }
277
+
278
+ export type JscpdExecutionResult =
279
+ | JscpdCompletedResult
280
+ | JscpdUnavailableResult
281
+ | JscpdScanFailureResult
282
+ | JscpdStatusResult
283
+ | JscpdControlResult
284
+ | JscpdHelpResult
285
+ | JscpdChangedResult
286
+ | JscpdChangedUnavailableResult;
287
+
288
+ export interface JscpdCommandExecutor {
289
+ /** Native application program; only Pi and test adapters execute it. */
290
+ executeEffect: (
291
+ invocation: JscpdCommandInvocation,
292
+ context: JscpdExecutionContext,
293
+ ) => import("effect").Effect.Effect<
294
+ JscpdExecutionResult,
295
+ never,
296
+ import("./effect/services.js").JscpdWorkflowRequirements
297
+ >;
298
+ }
299
+
300
+ export type JscpdInputErrorCode =
301
+ | "invalid-command"
302
+ | "unsupported-command"
303
+ | "invalid-arguments"
304
+ | "input-too-long"
305
+ | "too-many-arguments"
306
+ | "argument-too-long"
307
+ | "unclosed-quote";
308
+
309
+ export interface JscpdInputError {
310
+ code: JscpdInputErrorCode;
311
+ message: string;
312
+ }
313
+
314
+ export type JscpdParseResult =
315
+ | { ok: true; invocation: JscpdCommandInvocation }
316
+ | { ok: false; error: JscpdInputError };
317
+
318
+ export type JscpdSlashParseResult =
319
+ | { ok: true; kind: "bare" }
320
+ | { ok: true; kind: "command"; invocation: JscpdCommandInvocation }
321
+ | { ok: false; error: JscpdInputError };
322
+
323
+ export type JscpdDispatchResult =
324
+ | JscpdExecutionResult
325
+ | {
326
+ status: "invalid";
327
+ reason: JscpdInputErrorCode;
328
+ message: string;
329
+ }
330
+ | {
331
+ status: "error";
332
+ reason: "execution-failed";
333
+ message: string;
334
+ };
@@ -0,0 +1,14 @@
1
+ /** Narrow an untrusted value to a non-array object record. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+
6
+ /** Require an exact own-key set before untrusted persistence data enters domain state. */
7
+ export function hasExactKeys<T extends readonly string[]>(
8
+ value: unknown,
9
+ expected: T,
10
+ ): value is Record<T[number], unknown> {
11
+ if (!isRecord(value)) return false;
12
+ const keys = Object.keys(value);
13
+ return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key));
14
+ }
@@ -0,0 +1,220 @@
1
+ import { Context, Effect, Layer, MutableRef } from "effect";
2
+ import { compareJscpdCloneSnapshots, type JscpdCloneSnapshot } from "./clone-identity.js";
3
+ import type { JscpdChangedResult, JscpdCompletedResult, JscpdVerificationResult } from "./types.js";
4
+
5
+ export type JscpdVerificationKind = "changed" | "project";
6
+
7
+ export interface JscpdVerificationService {
8
+ compareAndRemember(
9
+ kind: JscpdVerificationKind,
10
+ scopeKey: string,
11
+ snapshot: JscpdCloneSnapshot,
12
+ expectedScope?: number,
13
+ ): JscpdVerificationResult;
14
+ compareAndRememberEffect?: (
15
+ kind: JscpdVerificationKind,
16
+ scopeKey: string,
17
+ snapshot: JscpdCloneSnapshot,
18
+ expectedScope?: number,
19
+ ) => Effect.Effect<JscpdVerificationResult>;
20
+ scope(): number;
21
+ readonly scopeEffect?: Effect.Effect<number>;
22
+ reset(): void;
23
+ }
24
+
25
+ interface VerificationCheckpoint {
26
+ readonly scopeKey: string;
27
+ readonly snapshot: JscpdCloneSnapshot;
28
+ }
29
+
30
+ interface VerificationState {
31
+ readonly scope: number;
32
+ readonly checkpoints: ReadonlyMap<JscpdVerificationKind, VerificationCheckpoint>;
33
+ }
34
+
35
+ interface JscpdVerificationEffectService {
36
+ readonly compareAndRemember: (
37
+ kind: JscpdVerificationKind,
38
+ scopeKey: string,
39
+ snapshot: JscpdCloneSnapshot,
40
+ expectedScope?: number,
41
+ ) => Effect.Effect<JscpdVerificationResult>;
42
+ readonly scope: Effect.Effect<number>;
43
+ readonly reset: Effect.Effect<void>;
44
+ }
45
+
46
+ export const JscpdVerification = Context.GenericTag<JscpdVerificationEffectService>(
47
+ "pi-jscpd/effect/Verification",
48
+ );
49
+
50
+ /** Keep one ephemeral pre-refactor checkpoint for each explicit scan kind. */
51
+ export function createJscpdVerificationService(): JscpdVerificationService {
52
+ return verificationServiceFor(new VerificationOwner());
53
+ }
54
+
55
+ export function createJscpdVerificationLayer() {
56
+ const owner = new VerificationOwner();
57
+ return Layer.succeed(JscpdVerification, verificationEffectServiceFor(owner));
58
+ }
59
+
60
+ class VerificationOwner {
61
+ readonly #state = MutableRef.make<VerificationState>({ scope: 0, checkpoints: new Map() });
62
+
63
+ compareAndRemember(
64
+ kind: JscpdVerificationKind,
65
+ scopeKey: string,
66
+ snapshot: JscpdCloneSnapshot,
67
+ expectedScope = this.scope(),
68
+ ): JscpdVerificationResult {
69
+ const state = MutableRef.get(this.#state);
70
+ if (state.scope !== expectedScope) return staleVerification(kind);
71
+ if (snapshot.status !== "accepted") return unavailableVerification(kind);
72
+ const previous = state.checkpoints.get(kind);
73
+ const checkpoints = new Map(state.checkpoints);
74
+ checkpoints.set(kind, Object.freeze({ scopeKey, snapshot }));
75
+ MutableRef.set(this.#state, { ...state, checkpoints });
76
+ return previous?.scopeKey === scopeKey
77
+ ? compareVerificationSnapshots(kind, previous.snapshot, snapshot)
78
+ : checkpointVerification(kind, snapshot.groups.length);
79
+ }
80
+
81
+ scope(): number {
82
+ return MutableRef.get(this.#state).scope;
83
+ }
84
+
85
+ reset(): void {
86
+ const state = MutableRef.get(this.#state);
87
+ MutableRef.set(this.#state, { scope: state.scope + 1, checkpoints: new Map() });
88
+ }
89
+ }
90
+
91
+ function compareVerificationSnapshots(
92
+ kind: JscpdVerificationKind,
93
+ previous: JscpdCloneSnapshot,
94
+ snapshot: JscpdCloneSnapshot,
95
+ ): JscpdVerificationResult {
96
+ const comparison = compareJscpdCloneSnapshots(previous, snapshot);
97
+ return comparedVerification(
98
+ kind,
99
+ comparison.removed.length,
100
+ comparison.existing.length,
101
+ comparison.new.length,
102
+ comparison.ambiguous.length,
103
+ );
104
+ }
105
+
106
+ function verificationServiceFor(owner: VerificationOwner): JscpdVerificationService {
107
+ return {
108
+ compareAndRemember: (kind, scopeKey, snapshot, expectedScope) =>
109
+ owner.compareAndRemember(kind, scopeKey, snapshot, expectedScope),
110
+ compareAndRememberEffect: (kind, scopeKey, snapshot, expectedScope) =>
111
+ Effect.sync(() => owner.compareAndRemember(kind, scopeKey, snapshot, expectedScope)),
112
+ scope: () => owner.scope(),
113
+ scopeEffect: Effect.sync(() => owner.scope()),
114
+ reset: () => owner.reset(),
115
+ };
116
+ }
117
+
118
+ export function jscpdVerificationScopeEffect(
119
+ service: JscpdVerificationService,
120
+ ): Effect.Effect<number> {
121
+ return service.scopeEffect ?? Effect.sync(() => service.scope());
122
+ }
123
+
124
+ export function compareAndRememberJscpdVerificationEffect(
125
+ service: JscpdVerificationService,
126
+ kind: JscpdVerificationKind,
127
+ scopeKey: string,
128
+ snapshot: JscpdCloneSnapshot,
129
+ expectedScope?: number,
130
+ ): Effect.Effect<JscpdVerificationResult> {
131
+ return (
132
+ service.compareAndRememberEffect?.(kind, scopeKey, snapshot, expectedScope) ??
133
+ Effect.sync(() => service.compareAndRemember(kind, scopeKey, snapshot, expectedScope))
134
+ );
135
+ }
136
+
137
+ function verificationEffectServiceFor(owner: VerificationOwner): JscpdVerificationEffectService {
138
+ return {
139
+ compareAndRemember: (kind, scopeKey, snapshot, expectedScope) =>
140
+ Effect.sync(() => owner.compareAndRemember(kind, scopeKey, snapshot, expectedScope)),
141
+ scope: Effect.sync(() => owner.scope()),
142
+ reset: Effect.sync(() => owner.reset()),
143
+ };
144
+ }
145
+
146
+ export function withJscpdVerification(
147
+ result: JscpdCompletedResult,
148
+ verification: JscpdVerificationResult,
149
+ ): JscpdCompletedResult;
150
+ export function withJscpdVerification(
151
+ result: JscpdChangedResult,
152
+ verification: JscpdVerificationResult,
153
+ ): JscpdChangedResult;
154
+ export function withJscpdVerification(
155
+ result: JscpdCompletedResult | JscpdChangedResult,
156
+ verification: JscpdVerificationResult,
157
+ ): JscpdCompletedResult | JscpdChangedResult {
158
+ return Object.freeze({
159
+ ...result,
160
+ message: `${result.message}\n${verification.message}`,
161
+ terminalMessage: `${result.terminalMessage}\n${verification.message}`,
162
+ verification,
163
+ });
164
+ }
165
+
166
+ function checkpointVerification(
167
+ kind: JscpdVerificationKind,
168
+ groups: number,
169
+ ): JscpdVerificationResult {
170
+ const route = kind === "changed" ? "/jscpd changed" : "the same /jscpd scan";
171
+ return Object.freeze({
172
+ state: "checkpoint",
173
+ scope: kind,
174
+ groups,
175
+ message: `Verification checkpoint recorded with ${counted(groups, "duplicate block")}. After ordinary edits and relevant tests, rerun ${route} to compare the result.`,
176
+ });
177
+ }
178
+
179
+ function comparedVerification(
180
+ kind: JscpdVerificationKind,
181
+ removed: number,
182
+ remaining: number,
183
+ created: number,
184
+ ambiguous: number,
185
+ ): JscpdVerificationResult {
186
+ const qualifier = ambiguous > 0 ? ` ${counted(ambiguous, "comparison")} remained ambiguous.` : "";
187
+ return Object.freeze({
188
+ state: "compared",
189
+ scope: kind,
190
+ removed,
191
+ remaining,
192
+ created,
193
+ ambiguous,
194
+ message: `Verification since the previous matching ${kind} scan: ${removed} removed, ${remaining} remaining, ${created} newly created.${qualifier}`,
195
+ });
196
+ }
197
+
198
+ function unavailableVerification(kind: JscpdVerificationKind): JscpdVerificationResult {
199
+ return Object.freeze({
200
+ state: "unavailable",
201
+ scope: kind,
202
+ reason: "identity-partial",
203
+ message:
204
+ "Verification comparison is unavailable because complete content identities could not be derived; the current scan result remains advisory.",
205
+ });
206
+ }
207
+
208
+ function staleVerification(kind: JscpdVerificationKind): JscpdVerificationResult {
209
+ return Object.freeze({
210
+ state: "unavailable",
211
+ scope: kind,
212
+ reason: "lifecycle-changed",
213
+ message:
214
+ "Verification comparison was discarded because the session or branch changed; rerun the explicit check in the current context.",
215
+ });
216
+ }
217
+
218
+ function counted(count: number, noun: string): string {
219
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
220
+ }