@clossys/writer 0.4.0 → 0.5.1
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.
- package/README.md +592 -2
- package/dist/approval-bypass.d.ts +120 -0
- package/dist/approval-bypass.d.ts.map +1 -0
- package/dist/approval-bypass.js +2318 -0
- package/dist/approval-bypass.js.map +1 -0
- package/dist/approval.d.ts +62 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +421 -0
- package/dist/approval.js.map +1 -0
- package/dist/approve-cli.d.ts +82 -0
- package/dist/approve-cli.d.ts.map +1 -0
- package/dist/approve-cli.js +430 -0
- package/dist/approve-cli.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +48 -13
- package/dist/cli.js.map +1 -1
- package/dist/field-options.d.ts +59 -0
- package/dist/field-options.d.ts.map +1 -0
- package/dist/field-options.js +109 -0
- package/dist/field-options.js.map +1 -0
- package/dist/front-door-browser.d.ts +5 -0
- package/dist/front-door-browser.d.ts.map +1 -0
- package/dist/front-door-browser.js +30 -0
- package/dist/front-door-browser.js.map +1 -0
- package/dist/front-door-core.d.ts +45 -0
- package/dist/front-door-core.d.ts.map +1 -0
- package/dist/front-door-core.js +224 -0
- package/dist/front-door-core.js.map +1 -0
- package/dist/front-door.check.d.ts +2 -0
- package/dist/front-door.check.d.ts.map +1 -0
- package/dist/front-door.check.js +8 -0
- package/dist/front-door.check.js.map +1 -0
- package/dist/front-door.d.ts +46 -0
- package/dist/front-door.d.ts.map +1 -0
- package/dist/front-door.js +61 -0
- package/dist/front-door.js.map +1 -0
- package/dist/generated/advisor-plan-contract.generated.d.ts +3 -0
- package/dist/generated/advisor-plan-contract.generated.d.ts.map +1 -0
- package/dist/generated/advisor-plan-contract.generated.js +430 -0
- package/dist/generated/advisor-plan-contract.generated.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -1
- package/dist/messaging-kit.d.ts +113 -0
- package/dist/messaging-kit.d.ts.map +1 -0
- package/dist/messaging-kit.js +121 -0
- package/dist/messaging-kit.js.map +1 -0
- package/dist/passage.d.ts +2 -1
- package/dist/passage.d.ts.map +1 -1
- package/dist/passage.js +2 -1
- package/dist/passage.js.map +1 -1
- package/dist/plan-authority.d.ts +73 -0
- package/dist/plan-authority.d.ts.map +1 -0
- package/dist/plan-authority.js +126 -0
- package/dist/plan-authority.js.map +1 -0
- package/dist/plan-contract.d.ts +16 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +33 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +23 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +82 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +54 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +145 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/resolve.d.ts +40 -6
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +144 -18
- package/dist/resolve.js.map +1 -1
- package/dist/scan.d.ts +32 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +67 -6
- package/dist/scan.js.map +1 -1
- package/dist/schema.d.ts +1 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +209 -5
- package/dist/schema.js.map +1 -1
- package/dist/site-identity.d.ts +88 -0
- package/dist/site-identity.d.ts.map +1 -0
- package/dist/site-identity.js +82 -0
- package/dist/site-identity.js.map +1 -0
- package/dist/types.d.ts +65 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +9 -7
- package/dist/types.js.map +1 -1
- package/package.json +11 -6
- package/skill/SKILL.md +2 -2
- package/src/approval-bypass.ts +2263 -0
- package/src/approval.ts +491 -0
- package/src/approve-cli.ts +474 -0
- package/src/cli.ts +46 -13
- package/src/field-options.ts +134 -0
- package/src/front-door-browser.ts +31 -0
- package/src/front-door-core.ts +265 -0
- package/src/front-door.check.ts +10 -0
- package/src/front-door.ts +65 -0
- package/src/generated/advisor-plan-contract.generated.ts +430 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/index.ts +97 -1
- package/src/messaging-kit.ts +195 -0
- package/src/passage.ts +2 -1
- package/src/plan-authority.ts +160 -0
- package/src/plan-contract.ts +45 -0
- package/src/plan-digest.ts +81 -0
- package/src/plan-rules.ts +179 -0
- package/src/resolve.ts +185 -19
- package/src/scan.ts +97 -6
- package/src/schema.ts +223 -5
- package/src/site-identity.ts +125 -0
- package/src/types.ts +72 -7
- package/templates/front-door.en.json +423 -0
package/src/resolve.ts
CHANGED
|
@@ -7,16 +7,29 @@
|
|
|
7
7
|
* source provenance, and an approved entry. The result exposes every one of
|
|
8
8
|
* those inputs so `@example/surface` can place them in its output
|
|
9
9
|
* manifest without knowing how a consumer stores copy.
|
|
10
|
+
*
|
|
11
|
+
* Target policy: an owner-approved entry resolves on both `"preview"` and
|
|
12
|
+
* `"production"` (the default target). A delegate-approved entry resolves
|
|
13
|
+
* freely on `"preview"`, but on `"production"` is refused
|
|
14
|
+
* (`delegate-approval-refused`) unless the caller opts in with
|
|
15
|
+
* `acceptDelegateInProduction: true`, or passes `approvalPlan`, the bytes of
|
|
16
|
+
* a plan whose latest approval names that plan's own digest and which
|
|
17
|
+
* declares `delegatedCopyApproval` covering the entry — a delegate's sign-off
|
|
18
|
+
* is not, by itself, sufficient evidence to publish, only to preview.
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
21
|
import type {
|
|
13
22
|
CopyRef,
|
|
14
23
|
CopyRegistry,
|
|
15
24
|
CopyResolution,
|
|
25
|
+
CopyResolutionApproval,
|
|
16
26
|
CopyResolver,
|
|
17
27
|
CopyValue,
|
|
18
28
|
} from "./types.js";
|
|
19
29
|
import { validateCopyRegistryShape } from "./schema.js";
|
|
30
|
+
import { isApprovalExpired, isApprovalStale, isEntryInDelegateScope } from "./approval.js";
|
|
31
|
+
import { isPlanBytes, planDelegateCopyAuthority } from "./plan-authority.js";
|
|
32
|
+
import type { PlanDelegateCopyRefusal } from "./plan-authority.js";
|
|
20
33
|
|
|
21
34
|
export type CopyResolveIssueReason =
|
|
22
35
|
| "invalid-registry"
|
|
@@ -25,13 +38,43 @@ export type CopyResolveIssueReason =
|
|
|
25
38
|
| "unknown-copy-id"
|
|
26
39
|
| "copy-not-approved"
|
|
27
40
|
| "missing-placeholder-value"
|
|
28
|
-
| "unexpected-placeholder-value"
|
|
41
|
+
| "unexpected-placeholder-value"
|
|
42
|
+
| "invalid-options"
|
|
43
|
+
| "approval-stale"
|
|
44
|
+
| "approval-expired"
|
|
45
|
+
| "delegate-approval-refused";
|
|
46
|
+
|
|
47
|
+
/** Which audience a resolution is for — see this file's top doc comment for the policy difference. */
|
|
48
|
+
export type CopyResolveTarget = "preview" | "production";
|
|
49
|
+
|
|
50
|
+
/** Options controlling how strictly `resolveCopyRef`/`createCopyResolver` treat an entry's approval record. */
|
|
51
|
+
export interface CopyResolveOptions {
|
|
52
|
+
/** Defaults to `"production"`. */
|
|
53
|
+
target?: CopyResolveTarget;
|
|
54
|
+
/** Defaults to `false`. Only meaningful when `target` is `"production"`. */
|
|
55
|
+
acceptDelegateInProduction?: boolean;
|
|
56
|
+
/** Defaults to `new Date()`, evaluated per call. */
|
|
57
|
+
now?: Date;
|
|
58
|
+
/**
|
|
59
|
+
* The bytes of an Advisor plan record (a `Uint8Array`; Node's `Buffer` is
|
|
60
|
+
* one), read by the caller. Consulted only for a delegate-approved entry on
|
|
61
|
+
* `"production"` when `acceptDelegateInProduction` is not `true`: see
|
|
62
|
+
* `planDelegateCopyAuthority()`. Not a `Uint8Array`, or given together with
|
|
63
|
+
* `acceptDelegateInProduction: true`, is `"invalid-options"`.
|
|
64
|
+
*/
|
|
65
|
+
approvalPlan?: Uint8Array;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Why an `approvalPlan` did not authorize an entry: the plan's own refusal, or that the entry is outside the scopes the plan declares. */
|
|
69
|
+
export type CopyResolvePlanRefusal = PlanDelegateCopyRefusal | "entry-outside-plan-scopes";
|
|
29
70
|
|
|
30
71
|
export interface CopyResolveIssue {
|
|
31
72
|
reason: CopyResolveIssueReason;
|
|
32
73
|
message: string;
|
|
33
74
|
id?: string;
|
|
34
75
|
placeholder?: string;
|
|
76
|
+
/** Only on `"delegate-approval-refused"` when an `approvalPlan` was given: why it did not authorize this entry. */
|
|
77
|
+
planRefusal?: CopyResolvePlanRefusal;
|
|
35
78
|
}
|
|
36
79
|
|
|
37
80
|
export interface CopyResolveResult {
|
|
@@ -56,13 +99,60 @@ function interpolate(text: string, values: Readonly<Record<string, CopyValue>>):
|
|
|
56
99
|
return text.replace(/\{([^{}]+)\}/g, (_match, name: string) => String(values[name]!));
|
|
57
100
|
}
|
|
58
101
|
|
|
102
|
+
/** The options a call runs with, each read from the caller's object exactly once. */
|
|
103
|
+
interface ResolveSettings {
|
|
104
|
+
target: CopyResolveTarget;
|
|
105
|
+
acceptDelegateInProduction: boolean;
|
|
106
|
+
now: Date;
|
|
107
|
+
approvalPlan: Uint8Array | undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reads a well-formed `CopyResolveOptions`, or returns null — checked before
|
|
112
|
+
* anything else so a malformed caller-supplied options object fails closed
|
|
113
|
+
* rather than silently falling back to defaults. Each property is read
|
|
114
|
+
* exactly once, into a local that is validated and then used, so a getter
|
|
115
|
+
* cannot answer differently at validation and at use.
|
|
116
|
+
*/
|
|
117
|
+
function readResolveOptions(value: unknown): ResolveSettings | null {
|
|
118
|
+
if (value === undefined) return { target: "production", acceptDelegateInProduction: false, now: new Date(), approvalPlan: undefined };
|
|
119
|
+
if (!isPlainObject(value)) return null;
|
|
120
|
+
const { target, acceptDelegateInProduction, now, approvalPlan } = value;
|
|
121
|
+
if (target !== undefined && target !== "preview" && target !== "production") return null;
|
|
122
|
+
if (acceptDelegateInProduction !== undefined && typeof acceptDelegateInProduction !== "boolean") return null;
|
|
123
|
+
if (now !== undefined && !(now instanceof Date && Number.isFinite(now.getTime()))) return null;
|
|
124
|
+
if (approvalPlan !== undefined && !isPlanBytes(approvalPlan)) return null;
|
|
125
|
+
// Two authorities for one decision is an ambiguity, so it is refused: no precedence needs defining.
|
|
126
|
+
if (approvalPlan !== undefined && acceptDelegateInProduction === true) return null;
|
|
127
|
+
return {
|
|
128
|
+
target: target ?? "production",
|
|
129
|
+
acceptDelegateInProduction: acceptDelegateInProduction ?? false,
|
|
130
|
+
now: now ?? new Date(),
|
|
131
|
+
approvalPlan,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
59
135
|
/**
|
|
60
136
|
* Resolves a `CopyRef` against one locale-specific registry. Any absence,
|
|
61
|
-
* lifecycle failure, locale mismatch,
|
|
62
|
-
* text and a non-complete
|
|
63
|
-
*
|
|
137
|
+
* lifecycle failure, locale mismatch, staleness/expiry, delegate-in-production
|
|
138
|
+
* refusal, or placeholder mismatch produces no text and a non-complete
|
|
139
|
+
* result. Consumers may log `issues`; renderers must treat a missing
|
|
140
|
+
* `resolution` as an unresolved required input.
|
|
64
141
|
*/
|
|
65
|
-
export function resolveCopyRef(
|
|
142
|
+
export function resolveCopyRef(
|
|
143
|
+
registry: CopyRegistry | unknown,
|
|
144
|
+
ref: CopyRef | unknown,
|
|
145
|
+
options?: CopyResolveOptions | unknown,
|
|
146
|
+
): CopyResolveResult {
|
|
147
|
+
const settings = readResolveOptions(options);
|
|
148
|
+
if (settings === null) {
|
|
149
|
+
return {
|
|
150
|
+
issues: [{ reason: "invalid-options", message: "CopyResolveOptions is malformed." }],
|
|
151
|
+
complete: false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const { target, acceptDelegateInProduction, now, approvalPlan } = settings;
|
|
155
|
+
|
|
66
156
|
const registryFindings = validateCopyRegistryShape(registry);
|
|
67
157
|
if (registryFindings.length > 0) {
|
|
68
158
|
return {
|
|
@@ -119,6 +209,69 @@ export function resolveCopyRef(registry: CopyRegistry | unknown, ref: CopyRef |
|
|
|
119
209
|
};
|
|
120
210
|
}
|
|
121
211
|
|
|
212
|
+
const approval = entry.approval;
|
|
213
|
+
// Set only when an approvalPlan, not the flag, authorized a delegate entry on production.
|
|
214
|
+
let authorizingPlanDigest: string | undefined;
|
|
215
|
+
if (approval) {
|
|
216
|
+
if (isApprovalStale(entry)) {
|
|
217
|
+
return {
|
|
218
|
+
issues: [
|
|
219
|
+
{
|
|
220
|
+
reason: "approval-stale",
|
|
221
|
+
id: validRef.id,
|
|
222
|
+
message: `CopyRef "${validRef.id}"'s approval record no longer matches its current text.`,
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
complete: false,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (isApprovalExpired(approval, now)) {
|
|
229
|
+
return {
|
|
230
|
+
issues: [
|
|
231
|
+
{
|
|
232
|
+
reason: "approval-expired",
|
|
233
|
+
id: validRef.id,
|
|
234
|
+
message: `CopyRef "${validRef.id}"'s approval expired at ${approval.expiresAt}.`,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
complete: false,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (approval.approvedBy === "delegate" && target === "production" && acceptDelegateInProduction !== true) {
|
|
241
|
+
// The flag did not authorize this entry, so only a plan can. The plan is
|
|
242
|
+
// read here and nowhere else: never for preview, an owner entry, a
|
|
243
|
+
// record-less entry, or once the flag is true, and never before the
|
|
244
|
+
// entry's own staleness and expiry, which outrank it.
|
|
245
|
+
if (approvalPlan === undefined) {
|
|
246
|
+
return {
|
|
247
|
+
issues: [
|
|
248
|
+
{
|
|
249
|
+
reason: "delegate-approval-refused",
|
|
250
|
+
id: validRef.id,
|
|
251
|
+
message: `CopyRef "${validRef.id}" was approved by a delegate; production resolution requires acceptDelegateInProduction: true.`,
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
complete: false,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const planRefused = (code: CopyResolvePlanRefusal): CopyResolveResult => ({
|
|
258
|
+
issues: [
|
|
259
|
+
{
|
|
260
|
+
reason: "delegate-approval-refused",
|
|
261
|
+
id: validRef.id,
|
|
262
|
+
planRefusal: code,
|
|
263
|
+
message: `CopyRef "${validRef.id}" was approved by a delegate; the approvalPlan given does not authorize it on production (${code}).`,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
complete: false,
|
|
267
|
+
});
|
|
268
|
+
const authority = planDelegateCopyAuthority(approvalPlan);
|
|
269
|
+
if (!authority.authorized) return planRefused(authority.refusal);
|
|
270
|
+
if (authority.scopes !== undefined && !isEntryInDelegateScope(entry.id, authority.scopes)) return planRefused("entry-outside-plan-scopes");
|
|
271
|
+
authorizingPlanDigest = authority.planDigest;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
122
275
|
const values = validRef.values ?? {};
|
|
123
276
|
const issues: CopyResolveIssue[] = [];
|
|
124
277
|
const declared = new Set(entry.placeholders ?? []);
|
|
@@ -151,22 +304,35 @@ export function resolveCopyRef(registry: CopyRegistry | unknown, ref: CopyRef |
|
|
|
151
304
|
}
|
|
152
305
|
if (issues.length > 0) return { issues, complete: false };
|
|
153
306
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
entryId: entry.id,
|
|
163
|
-
},
|
|
164
|
-
issues: [],
|
|
165
|
-
complete: true,
|
|
307
|
+
const resolution: CopyResolution = {
|
|
308
|
+
ref: validRef as unknown as CopyRef,
|
|
309
|
+
text: interpolate(entry.text, values as Readonly<Record<string, CopyValue>>),
|
|
310
|
+
recordId: validRegistry.id,
|
|
311
|
+
revision: validRegistry.revision,
|
|
312
|
+
locale: validRegistry.locale,
|
|
313
|
+
source: validRegistry.source,
|
|
314
|
+
entryId: entry.id,
|
|
166
315
|
};
|
|
316
|
+
// Only set when the entry actually carries a record — an approved entry
|
|
317
|
+
// with no record resolves exactly as it did before this field existed,
|
|
318
|
+
// with no `approval` key on the resolution at all.
|
|
319
|
+
if (approval) {
|
|
320
|
+
// Built explicitly, so the digest key exists only when a plan authorized the entry.
|
|
321
|
+
const facts: CopyResolutionApproval =
|
|
322
|
+
approval.approvedBy === "owner"
|
|
323
|
+
? { approvedBy: "owner", pendingOwnerReview: false }
|
|
324
|
+
: {
|
|
325
|
+
approvedBy: "delegate",
|
|
326
|
+
pendingOwnerReview: approval.pendingOwnerReview === true,
|
|
327
|
+
...(authorizingPlanDigest === undefined ? {} : { authorizingPlanDigest }),
|
|
328
|
+
};
|
|
329
|
+
resolution.approval = facts;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { resolution, issues: [], complete: true };
|
|
167
333
|
}
|
|
168
334
|
|
|
169
335
|
/** Creates the narrow resolver callback for presentation code. */
|
|
170
|
-
export function createCopyResolver(registry: CopyRegistry | unknown): CopyResolver {
|
|
171
|
-
return (ref) => resolveCopyRef(registry, ref).resolution;
|
|
336
|
+
export function createCopyResolver(registry: CopyRegistry | unknown, options?: CopyResolveOptions | unknown): CopyResolver {
|
|
337
|
+
return (ref) => resolveCopyRef(registry, ref, options).resolution;
|
|
172
338
|
}
|
package/src/scan.ts
CHANGED
|
@@ -634,6 +634,14 @@ interface TokenizeResult {
|
|
|
634
634
|
literals: Literal[];
|
|
635
635
|
jsxTexts: JsxTextRun[];
|
|
636
636
|
unchecked: RawUnchecked[];
|
|
637
|
+
/**
|
|
638
|
+
* Half-open `[start, end)` ranges of `content` that are NOT code: every
|
|
639
|
+
* comment, every string literal, every template literal's static segments
|
|
640
|
+
* (never its `${...}` interpolations, which are code), every regex literal
|
|
641
|
+
* body, and every JSX text run. Read only by `maskNonCode` below; every
|
|
642
|
+
* other caller ignores it.
|
|
643
|
+
*/
|
|
644
|
+
opaque: Array<[number, number]>;
|
|
637
645
|
/** Set when the scanner could not reach a consistent end-of-file state (unterminated string/template/comment, or a regex heuristic that desynced quote tracking). */
|
|
638
646
|
failure?: string;
|
|
639
647
|
}
|
|
@@ -687,6 +695,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
687
695
|
const literals: Literal[] = [];
|
|
688
696
|
const jsxTexts: JsxTextRun[] = [];
|
|
689
697
|
const unchecked: RawUnchecked[] = [];
|
|
698
|
+
const opaque: Array<[number, number]> = [];
|
|
690
699
|
const n = content.length;
|
|
691
700
|
let i = 0;
|
|
692
701
|
let line = 1;
|
|
@@ -745,6 +754,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
745
754
|
}
|
|
746
755
|
if (ck === '"' || ck === "'") {
|
|
747
756
|
const q = ck;
|
|
757
|
+
const stringStart = j;
|
|
748
758
|
j++;
|
|
749
759
|
while (j < n && content[j] !== q) {
|
|
750
760
|
if (content[j] === "\\") j++;
|
|
@@ -752,6 +762,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
752
762
|
j++;
|
|
753
763
|
}
|
|
754
764
|
j++; // consume closing quote, or run past EOF — caught by the outer unterminated-template check
|
|
765
|
+
opaque.push([stringStart, Math.min(j, n)]);
|
|
755
766
|
continue;
|
|
756
767
|
}
|
|
757
768
|
if (ck === "`") {
|
|
@@ -762,6 +773,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
762
773
|
}
|
|
763
774
|
if (ck === "/" && content[j + 1] === "/") {
|
|
764
775
|
const nl = content.indexOf("\n", j);
|
|
776
|
+
opaque.push([j, nl === -1 ? n : nl]);
|
|
765
777
|
j = nl === -1 ? n : nl;
|
|
766
778
|
continue;
|
|
767
779
|
}
|
|
@@ -769,6 +781,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
769
781
|
const close = content.indexOf("*/", j + 2);
|
|
770
782
|
if (close === -1) return -1;
|
|
771
783
|
advanceLine(j, close + 2);
|
|
784
|
+
opaque.push([j, close + 2]);
|
|
772
785
|
j = close + 2;
|
|
773
786
|
continue;
|
|
774
787
|
}
|
|
@@ -780,22 +793,28 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
780
793
|
/** Skips an entire template literal starting at `pos` (`content[pos] === "\`"`), including every interpolation it contains, recursively. Returns the index one past its closing backtick, or `-1` if unterminated. */
|
|
781
794
|
function skipTemplateFrom(pos: number): number {
|
|
782
795
|
let j = pos + 1;
|
|
796
|
+
let segmentStart = pos;
|
|
783
797
|
while (j < n) {
|
|
784
798
|
const cj = content[j] as string;
|
|
785
799
|
if (cj === "\\" && j + 1 < n) {
|
|
786
800
|
j += 2;
|
|
787
801
|
continue;
|
|
788
802
|
}
|
|
789
|
-
if (cj === "`")
|
|
803
|
+
if (cj === "`") {
|
|
804
|
+
opaque.push([segmentStart, j + 1]);
|
|
805
|
+
return j + 1;
|
|
806
|
+
}
|
|
790
807
|
if (cj === "\n") {
|
|
791
808
|
line++;
|
|
792
809
|
j++;
|
|
793
810
|
continue;
|
|
794
811
|
}
|
|
795
812
|
if (cj === "$" && content[j + 1] === "{") {
|
|
813
|
+
opaque.push([segmentStart, j]);
|
|
796
814
|
const next = skipInterpolation(j + 2);
|
|
797
815
|
if (next === -1) return -1;
|
|
798
816
|
j = next;
|
|
817
|
+
segmentStart = j;
|
|
799
818
|
continue;
|
|
800
819
|
}
|
|
801
820
|
j++;
|
|
@@ -858,6 +877,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
858
877
|
literalFailure = `unterminated string starting at line ${startLine}`;
|
|
859
878
|
return -1;
|
|
860
879
|
}
|
|
880
|
+
opaque.push([start, j]);
|
|
861
881
|
literals.push({
|
|
862
882
|
kind: "string",
|
|
863
883
|
line: startLine,
|
|
@@ -892,6 +912,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
892
912
|
let j = pos + 1;
|
|
893
913
|
let current = "";
|
|
894
914
|
let closed = false;
|
|
915
|
+
let segmentStart = pos;
|
|
895
916
|
while (j < n) {
|
|
896
917
|
const cj = content[j] as string;
|
|
897
918
|
if (cj === "\\" && j + 1 < n) {
|
|
@@ -905,6 +926,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
905
926
|
break;
|
|
906
927
|
}
|
|
907
928
|
if (cj === "$" && content[j + 1] === "{") {
|
|
929
|
+
opaque.push([segmentStart, j]);
|
|
908
930
|
staticParts.push(unescape(current));
|
|
909
931
|
current = "";
|
|
910
932
|
placeholderCount++;
|
|
@@ -920,6 +942,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
920
942
|
return -1;
|
|
921
943
|
}
|
|
922
944
|
j = next;
|
|
945
|
+
segmentStart = j;
|
|
923
946
|
continue;
|
|
924
947
|
}
|
|
925
948
|
if (cj === "\n") line++;
|
|
@@ -931,6 +954,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
931
954
|
return -1;
|
|
932
955
|
}
|
|
933
956
|
staticParts.push(unescape(current));
|
|
957
|
+
opaque.push([segmentStart, j]);
|
|
934
958
|
literals.push({
|
|
935
959
|
kind: "template",
|
|
936
960
|
line: startLine,
|
|
@@ -981,6 +1005,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
981
1005
|
}
|
|
982
1006
|
if (cj === "/" && content[j + 1] === "/") {
|
|
983
1007
|
const nl = content.indexOf("\n", j);
|
|
1008
|
+
opaque.push([j, nl === -1 ? n : nl]);
|
|
984
1009
|
j = nl === -1 ? n : nl;
|
|
985
1010
|
continue;
|
|
986
1011
|
}
|
|
@@ -988,6 +1013,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
988
1013
|
const close = content.indexOf("*/", j + 2);
|
|
989
1014
|
if (close === -1) return -1;
|
|
990
1015
|
advanceLine(j, close + 2);
|
|
1016
|
+
opaque.push([j, close + 2]);
|
|
991
1017
|
j = close + 2;
|
|
992
1018
|
continue;
|
|
993
1019
|
}
|
|
@@ -1079,6 +1105,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1079
1105
|
if (found !== -1) {
|
|
1080
1106
|
let m = found + 1;
|
|
1081
1107
|
while (m < n && /[a-z]/i.test(content[m] as string)) m++;
|
|
1108
|
+
opaque.push([j, m]);
|
|
1082
1109
|
j = m;
|
|
1083
1110
|
localLast = "value";
|
|
1084
1111
|
continue;
|
|
@@ -1116,7 +1143,10 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1116
1143
|
const flush = (): void => {
|
|
1117
1144
|
if (j > runStart) {
|
|
1118
1145
|
const raw = content.slice(runStart, j);
|
|
1119
|
-
if (raw.trim().length > 0)
|
|
1146
|
+
if (raw.trim().length > 0) {
|
|
1147
|
+
jsxTexts.push({ line: runStartLine, raw });
|
|
1148
|
+
opaque.push([runStart, j]);
|
|
1149
|
+
}
|
|
1120
1150
|
}
|
|
1121
1151
|
};
|
|
1122
1152
|
|
|
@@ -1268,12 +1298,14 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1268
1298
|
const entryLiteralsCount = literals.length;
|
|
1269
1299
|
const entryJsxTextsCount = jsxTexts.length;
|
|
1270
1300
|
const entryUncheckedCount = unchecked.length;
|
|
1301
|
+
const entryOpaqueCount = opaque.length;
|
|
1271
1302
|
|
|
1272
1303
|
function backtrack(): null {
|
|
1273
1304
|
line = entryLine;
|
|
1274
1305
|
literals.length = entryLiteralsCount;
|
|
1275
1306
|
jsxTexts.length = entryJsxTextsCount;
|
|
1276
1307
|
unchecked.length = entryUncheckedCount;
|
|
1308
|
+
opaque.length = entryOpaqueCount;
|
|
1277
1309
|
return null;
|
|
1278
1310
|
}
|
|
1279
1311
|
|
|
@@ -1339,6 +1371,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1339
1371
|
// above.
|
|
1340
1372
|
if (cj === "/" && content[j + 1] === "/") {
|
|
1341
1373
|
const nl = content.indexOf("\n", j);
|
|
1374
|
+
opaque.push([j, nl === -1 ? n : nl]);
|
|
1342
1375
|
j = nl === -1 ? n : nl;
|
|
1343
1376
|
continue;
|
|
1344
1377
|
}
|
|
@@ -1346,6 +1379,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1346
1379
|
const close = content.indexOf("*/", j + 2);
|
|
1347
1380
|
if (close === -1) return backtrack(); // unterminated block comment — not committed, silently not-JSX
|
|
1348
1381
|
advanceLine(j, close + 2);
|
|
1382
|
+
opaque.push([j, close + 2]);
|
|
1349
1383
|
j = close + 2;
|
|
1350
1384
|
continue;
|
|
1351
1385
|
}
|
|
@@ -1456,6 +1490,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1456
1490
|
// line comment
|
|
1457
1491
|
if (c === "/" && content[i + 1] === "/") {
|
|
1458
1492
|
const nl = content.indexOf("\n", i);
|
|
1493
|
+
opaque.push([i, nl === -1 ? n : nl]);
|
|
1459
1494
|
i = nl === -1 ? n : nl;
|
|
1460
1495
|
continue;
|
|
1461
1496
|
}
|
|
@@ -1463,8 +1498,9 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1463
1498
|
// block comment
|
|
1464
1499
|
if (c === "/" && content[i + 1] === "*") {
|
|
1465
1500
|
const close = content.indexOf("*/", i + 2);
|
|
1466
|
-
if (close === -1) return { literals, jsxTexts, unchecked, failure: `unterminated block comment starting at line ${line}` };
|
|
1501
|
+
if (close === -1) return { literals, jsxTexts, unchecked, opaque, failure: `unterminated block comment starting at line ${line}` };
|
|
1467
1502
|
advanceLine(i, close + 2);
|
|
1503
|
+
opaque.push([i, close + 2]);
|
|
1468
1504
|
i = close + 2;
|
|
1469
1505
|
continue;
|
|
1470
1506
|
}
|
|
@@ -1472,7 +1508,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1472
1508
|
// string literal
|
|
1473
1509
|
if (c === '"' || c === "'") {
|
|
1474
1510
|
const end = scanStringLiteral(i);
|
|
1475
|
-
if (end === -1) return { literals, jsxTexts, unchecked, failure: literalFailure };
|
|
1511
|
+
if (end === -1) return { literals, jsxTexts, unchecked, opaque, failure: literalFailure };
|
|
1476
1512
|
lastTokenType = "value";
|
|
1477
1513
|
i = end;
|
|
1478
1514
|
continue;
|
|
@@ -1481,7 +1517,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1481
1517
|
// template literal
|
|
1482
1518
|
if (c === "`") {
|
|
1483
1519
|
const end = scanTemplateLiteral(i);
|
|
1484
|
-
if (end === -1) return { literals, jsxTexts, unchecked, failure: literalFailure };
|
|
1520
|
+
if (end === -1) return { literals, jsxTexts, unchecked, opaque, failure: literalFailure };
|
|
1485
1521
|
lastTokenType = "value";
|
|
1486
1522
|
i = end;
|
|
1487
1523
|
continue;
|
|
@@ -1540,6 +1576,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1540
1576
|
if (found !== -1) {
|
|
1541
1577
|
let k = found + 1;
|
|
1542
1578
|
while (k < n && /[a-z]/i.test(content[k] as string)) k++;
|
|
1579
|
+
opaque.push([i, k]);
|
|
1543
1580
|
i = k;
|
|
1544
1581
|
lastTokenType = "value";
|
|
1545
1582
|
continue;
|
|
@@ -1581,7 +1618,7 @@ function tokenize(content: string, isJsxFile: boolean): TokenizeResult {
|
|
|
1581
1618
|
i++;
|
|
1582
1619
|
}
|
|
1583
1620
|
|
|
1584
|
-
return { literals, jsxTexts, unchecked };
|
|
1621
|
+
return { literals, jsxTexts, unchecked, opaque };
|
|
1585
1622
|
}
|
|
1586
1623
|
|
|
1587
1624
|
// ---------------------------------------------------------- classification
|
|
@@ -1988,3 +2025,57 @@ export function extractCopyCandidates(content: string, filePath: string): Extrac
|
|
|
1988
2025
|
|
|
1989
2026
|
return { candidates, excluded, citations, unchecked };
|
|
1990
2027
|
}
|
|
2028
|
+
|
|
2029
|
+
/** One string or template literal `maskNonCode` found, with its position in the original content. */
|
|
2030
|
+
export interface MaskedLiteral {
|
|
2031
|
+
kind: "string" | "template";
|
|
2032
|
+
line: number;
|
|
2033
|
+
/** Index of the opening quote/backtick. */
|
|
2034
|
+
start: number;
|
|
2035
|
+
/** Index one past the closing quote/backtick. */
|
|
2036
|
+
end: number;
|
|
2037
|
+
raw: string;
|
|
2038
|
+
/** The literal's static text, unescaped; a template's interpolations are dropped. */
|
|
2039
|
+
text: string;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/** `maskNonCode`'s result. `code` has the same length and line breaks as the input. */
|
|
2043
|
+
export interface CodeMask {
|
|
2044
|
+
code: string;
|
|
2045
|
+
literals: MaskedLiteral[];
|
|
2046
|
+
failure?: string;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Pure. Runs this file's tokenizer over `content` and returns a copy of it in
|
|
2051
|
+
* which every comment, string literal, template static segment, regex literal
|
|
2052
|
+
* and (in a `.tsx`/`.jsx` file) JSX text run is replaced by spaces, keeping
|
|
2053
|
+
* every newline and every character offset. A caller can then match code
|
|
2054
|
+
* patterns against `code` without a comment or a string ever matching, and
|
|
2055
|
+
* look up the literal at a given offset through `literals`. A template's
|
|
2056
|
+
* `${...}` interpolations stay visible in `code`, because they are code.
|
|
2057
|
+
*
|
|
2058
|
+
* Exported for `approval-bypass.ts` so it delegates literal and comment
|
|
2059
|
+
* boundaries to this tokenizer instead of growing a second one. Deliberately
|
|
2060
|
+
* not re-exported from `index.ts`: it is an internal helper, not package API.
|
|
2061
|
+
*/
|
|
2062
|
+
export function maskNonCode(content: string, filePath: string): CodeMask {
|
|
2063
|
+
const { literals, opaque, failure } = tokenize(content, JSX_FILE_RE.test(filePath));
|
|
2064
|
+
const masked = content.split("");
|
|
2065
|
+
if (!failure) {
|
|
2066
|
+
for (const [start, end] of opaque) {
|
|
2067
|
+
for (let k = start; k < end && k < masked.length; k++) {
|
|
2068
|
+
if (masked[k] !== "\n") masked[k] = " ";
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
const out: MaskedLiteral[] = literals.map((lit) => ({
|
|
2073
|
+
kind: lit.kind,
|
|
2074
|
+
line: lit.line,
|
|
2075
|
+
start: lit.start,
|
|
2076
|
+
end: lit.end,
|
|
2077
|
+
raw: lit.raw,
|
|
2078
|
+
text: lit.staticParts.join(""),
|
|
2079
|
+
}));
|
|
2080
|
+
return failure ? { code: masked.join(""), literals: out, failure } : { code: masked.join(""), literals: out };
|
|
2081
|
+
}
|