@clossys/writer 0.3.9 → 0.4.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 (46) hide show
  1. package/README.md +60 -4
  2. package/dist/addressability.d.ts +11 -1
  3. package/dist/addressability.d.ts.map +1 -1
  4. package/dist/addressability.js +169 -3
  5. package/dist/addressability.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +216 -9
  8. package/dist/cli.js.map +1 -1
  9. package/dist/fold-wallpaper-gate.d.ts +7 -0
  10. package/dist/fold-wallpaper-gate.d.ts.map +1 -0
  11. package/dist/fold-wallpaper-gate.js +29 -0
  12. package/dist/fold-wallpaper-gate.js.map +1 -0
  13. package/dist/index.d.ts +4 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/live-copy-gate.d.ts +16 -0
  18. package/dist/live-copy-gate.d.ts.map +1 -0
  19. package/dist/live-copy-gate.js +135 -0
  20. package/dist/live-copy-gate.js.map +1 -0
  21. package/dist/render-registry-parity.d.ts +20 -0
  22. package/dist/render-registry-parity.d.ts.map +1 -0
  23. package/dist/render-registry-parity.js +45 -0
  24. package/dist/render-registry-parity.js.map +1 -0
  25. package/dist/schema.d.ts.map +1 -1
  26. package/dist/schema.js +22 -0
  27. package/dist/schema.js.map +1 -1
  28. package/dist/treatment-word-budget.d.ts +31 -0
  29. package/dist/treatment-word-budget.d.ts.map +1 -0
  30. package/dist/treatment-word-budget.js +80 -0
  31. package/dist/treatment-word-budget.js.map +1 -0
  32. package/dist/types.d.ts +11 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/fit-signals.json +11 -0
  35. package/package.json +195 -6
  36. package/skill/SKILL.md +49 -0
  37. package/src/addressability.ts +186 -4
  38. package/src/cli.ts +228 -9
  39. package/src/fold-wallpaper-gate.ts +29 -0
  40. package/src/index.ts +10 -0
  41. package/src/live-copy-gate.ts +150 -0
  42. package/src/render-registry-parity.ts +65 -0
  43. package/src/schema.ts +24 -0
  44. package/src/treatment-word-budget.ts +100 -0
  45. package/src/types.ts +11 -0
  46. package/CHANGELOG.md +0 -330
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clossys/writer",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,18 +27,207 @@
27
27
  "src",
28
28
  "!src/**/*.test.ts",
29
29
  "templates",
30
- "CHANGELOG.md",
30
+ "fit-signals.json",
31
31
  "README.md",
32
- "LICENSE"
32
+ "LICENSE",
33
+ "skill"
33
34
  ],
34
35
  "sideEffects": false,
35
36
  "foundry": {
36
37
  "assessment": {
37
38
  "bin": "writer-rate-check",
38
39
  "invocation": "single-json-input"
39
- }
40
+ },
41
+ "capabilities": [
42
+ {
43
+ "id": "voice-record",
44
+ "subQuestion": "Is the voice record itself complete — every field bound, every rule derived from a real brand attribute?",
45
+ "worldClass": "The voice record is fully bound (no unedited template placeholder) and every rule traces to a brand-derivation attribute — checked as a record, before any copy is ever measured against it.",
46
+ "inputs": [
47
+ {
48
+ "producerRole": "@clossys/strategist",
49
+ "artifact": "brand-derivation"
50
+ }
51
+ ],
52
+ "outputs": [
53
+ "clossys/writer/voice.json"
54
+ ],
55
+ "proofCase": "voice-derivation-violated",
56
+ "maturity": "partial",
57
+ "v0": true,
58
+ "businessLifecycleStage": "define"
59
+ },
60
+ {
61
+ "id": "copy-registry",
62
+ "subQuestion": "Is every registry entry itself schema-valid, unique, and free of unfilled placeholders?",
63
+ "worldClass": "Every registry entry is schema-valid, has a unique id, and every declared placeholder is present in its own text — registry validity only; whether a component actually USES the registry by id is addressability's own question.",
64
+ "inputs": [],
65
+ "outputs": [
66
+ "clossys/writer/copy-registry.json"
67
+ ],
68
+ "proofCase": "registry-placeholder-missing",
69
+ "maturity": "built",
70
+ "v0": true,
71
+ "businessLifecycleStage": "build"
72
+ },
73
+ {
74
+ "id": "copy-coverage",
75
+ "subQuestion": "Does every user-facing string in code trace to a registered copy entry, by literal match or citation?",
76
+ "worldClass": "Every component string either matches a registered CopyRecord entry's text/alias or carries a copy:<id> citation -- the \"approved copy coverage rate\" metric this role owns (docs/contracts/role-loop-archetypes.json), and what writer-check's default command (copy-clean) actually proves. Distinct from `copy-registry` (is the registry entry itself valid) and from `claims-to-copy-traceability` (does a claim-SHAPED sentence cite an approved Strategist claim).",
77
+ "inputs": [
78
+ {
79
+ "producerRole": "@clossys/writer",
80
+ "artifact": "copy-registry"
81
+ }
82
+ ],
83
+ "outputs": [
84
+ "clossys/writer/.generated/copy-coverage-report.json"
85
+ ],
86
+ "proofCase": "copy-clean",
87
+ "maturity": "built",
88
+ "v0": true,
89
+ "businessLifecycleStage": "build"
90
+ },
91
+ {
92
+ "id": "claims-to-copy-traceability",
93
+ "subQuestion": "Does every claim-shaped or numeric sentence in shipped copy trace to an approved registry claim, by citation?",
94
+ "worldClass": "A currency amount, percentage, multiplier, or superlative sentence in live copy either matches a registered claim's text or carries a claim:<id> citation to one -- checked on the COPY side. Whether Strategist's own facts/markers gate should stop scanning shipped surfaces at all (leaving this the one place that check happens) is a decision pending #1271, not yet a settled boundary -- today `strategist-check` still scans authored prose too (its own facts-clean fixture is an announcement.md).",
95
+ "inputs": [
96
+ {
97
+ "producerRole": "@clossys/strategist",
98
+ "artifact": "claims"
99
+ }
100
+ ],
101
+ "outputs": [
102
+ "clossys/writer/.generated/claim-citations-report.json"
103
+ ],
104
+ "proofCase": "live-magnitude-unmarked",
105
+ "maturity": "partial",
106
+ "v0": true,
107
+ "businessLifecycleStage": "build"
108
+ },
109
+ {
110
+ "id": "live-copy-conformance",
111
+ "subQuestion": "Does live, shipped copy still match its registry source and stay within its word-budget and fold rules?",
112
+ "worldClass": "Shipped copy never drifts silently from its registry source, and the above-the-fold word budget is enforced, not eyeballed.",
113
+ "inputs": [],
114
+ "outputs": [
115
+ "clossys/writer/.generated/live-copy-report.json"
116
+ ],
117
+ "proofCase": "live-fold-wallpaper",
118
+ "maturity": "partial",
119
+ "v0": false,
120
+ "businessLifecycleStage": "operate"
121
+ },
122
+ {
123
+ "id": "addressability",
124
+ "subQuestion": "Is every piece of user-facing prose resolved from the registry by id, never typed directly into a component?",
125
+ "worldClass": "A rename is always cheap: renaming a piece of copy means editing one registry entry, never hunting every component that typed the sentence — #1208's site template reads copy by id on this guarantee.",
126
+ "inputs": [],
127
+ "outputs": [
128
+ "clossys/writer/.generated/addressability-report.json"
129
+ ],
130
+ "proofCase": "addressability-violated",
131
+ "maturity": "built",
132
+ "v0": true,
133
+ "businessLifecycleStage": "build"
134
+ },
135
+ {
136
+ "id": "locale-coverage",
137
+ "subQuestion": "Does the whole set of a project's locales actually cover the same ground, current and correctly interpolating?",
138
+ "worldClass": "Every target locale covers what the source locale covers, with no orphaned or stale translation — governance over WHICH locales are covered, not runtime translation itself.",
139
+ "inputs": [],
140
+ "outputs": [
141
+ "clossys/writer/.generated/locale-coverage-report.json"
142
+ ],
143
+ "proofCase": "locale-coverage-missing",
144
+ "maturity": "built",
145
+ "v0": false,
146
+ "businessLifecycleStage": "operate"
147
+ },
148
+ {
149
+ "id": "voice-conformance",
150
+ "subQuestion": "Is shipped copy actually well said — in voice, in tense, in person, every claim supported?",
151
+ "worldClass": "Every mechanically detectable voice violation (forbidden term, wrong tense or person, an unsupported claim marked as requiring support) is caught against the bound voice record — the answer to \"is it well said\", not merely \"does it trace\".",
152
+ "inputs": [
153
+ {
154
+ "producerRole": "@clossys/writer",
155
+ "artifact": "voice-record"
156
+ }
157
+ ],
158
+ "outputs": [
159
+ "clossys/writer/.generated/voice-conformance-report.json"
160
+ ],
161
+ "proofCase": "live-claim-unsupported",
162
+ "maturity": "partial",
163
+ "v0": false,
164
+ "businessLifecycleStage": "operate"
165
+ },
166
+ {
167
+ "id": "messaging-kit",
168
+ "subQuestion": "Does the product have one governed tagline, pitch-length set, boilerplate, and FAQ that everything else derives from?",
169
+ "worldClass": "One governed messaging kit (tagline, short/medium/long pitch, short/medium/long boilerplate, FAQ) exists and every surface's short-form copy derives from it, per #1204's Identity layer, rather than being improvised per surface.",
170
+ "inputs": [
171
+ {
172
+ "producerRole": "@clossys/strategist",
173
+ "artifact": "positioning"
174
+ }
175
+ ],
176
+ "outputs": [
177
+ "clossys/writer/messaging-kit.json"
178
+ ],
179
+ "proofCase": null,
180
+ "maturity": "planned",
181
+ "v0": true,
182
+ "businessLifecycleStage": "launch"
183
+ },
184
+ {
185
+ "id": "privacy-and-terms-placeholders",
186
+ "subQuestion": "Do the placeholder privacy notice and terms say, in the brand's voice, only what the supplied data-holding facts support, and say plainly that they are not legal advice?",
187
+ "worldClass": "Placeholder privacy-notice and terms text is drafted in the brand's voice from the facts Keeper supplies (what data is held, why, and for how long), never from assumption, and each page is labelled clearly as not legal advice pending review by counsel (#1213). Legal work beyond placeholders is out of scope for this role.",
188
+ "inputs": [],
189
+ "outputs": [
190
+ "clossys/writer/policy-placeholders.json"
191
+ ],
192
+ "proofCase": null,
193
+ "maturity": "planned",
194
+ "v0": true,
195
+ "businessLifecycleStage": "launch"
196
+ }
197
+ ],
198
+ "outputs": [
199
+ "clossys/writer/copy-registry.json",
200
+ "clossys/writer/voice.json"
201
+ ],
202
+ "feeds": [
203
+ {
204
+ "artifact": "copy-registry",
205
+ "path": "clossys/writer/copy-registry.json"
206
+ }
207
+ ],
208
+ "needs": [
209
+ {
210
+ "producerRole": "@clossys/strategist",
211
+ "artifact": "brand-derivation"
212
+ },
213
+ {
214
+ "producerRole": "@clossys/strategist",
215
+ "artifact": "claims"
216
+ }
217
+ ],
218
+ "solves": [
219
+ {
220
+ "problem": "writer-unapproved-copy",
221
+ "statement": "We ship words nobody signed off on.",
222
+ "metric": "approved copy coverage rate",
223
+ "proofCase": "copy-violated",
224
+ "evidence": "designed",
225
+ "capability": "copy-coverage"
226
+ }
227
+ ],
228
+ "fit": "fit-signals.json"
40
229
  },
41
- "description": "The writer role: is it well said? A consumer-owned language system: voice rules, glossary and claims validation, addressable copy records, source traceability, locale coverage, and passage-layer composition — five gates reachable from one bin. Ships machinery and templates, never a consumer's actual copy or voice.",
230
+ "description": "The writer role: is it well said? A consumer-owned language system: voice rules, glossary and claims validation, addressable copy records, source traceability, locale coverage, and passage-layer composition \u2014 five gates reachable from one bin. Ships machinery and templates, never a consumer's actual copy or voice.",
42
231
  "keywords": [
43
232
  "writer",
44
233
  "copy",
@@ -70,7 +259,7 @@
70
259
  "devDependencies": {
71
260
  "@types/node": "^26.5.1",
72
261
  "typescript": "~6.0.0",
73
- "vitest": "^5.0.0"
262
+ "vitest": "^5.0.1"
74
263
  },
75
264
  "publishConfig": {
76
265
  "registry": "https://registry.npmjs.org",
package/skill/SKILL.md ADDED
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: clossys-writer
3
+ description: Copy registry, voice conformance, and approved-language coverage. Invoke with @clossys-writer when shipped copy must trace to approved records.
4
+ disable-model-invocation: true
5
+ ---
6
+ # clossys-writer
7
+
8
+ You are Writer. Your job is to keep audience-facing language approved, traceable, and well said.
9
+
10
+ You maintain approved copy records for a named page, voice conformance, and language traceability. With Designer you supply the copy ids and block kinds the in-tree page document cites by reference, and iterate until a local render of that document is the page; Publisher authors and owns the surface document itself (#1205). You do not invent strategy facts, treat yourself as outline-only for Publisher to finish, or publish surfaces.
11
+
12
+
13
+ ## Foundry voices
14
+
15
+ The same team is in every inventoried repo. Name another `@clossys-<package>` to talk to them. A missing mention is a bug, not a compatibility signal. Hiring and fit always go through `@clossys-advisor`.
16
+
17
+ ## Operating wave
18
+
19
+ 1. **Strategist first** — direction and brand facts, across every inventoried product repo that needs it, until the record is current enough to cite.
20
+ 2. **Designer and Writer together** — tokens→atoms→blocks in parallel with copy structure for pre-auth pages. Do not start if Strategist still has no citable direction.
21
+ 3. **Customer inhabit** — independent `@clossys-customer` session speaks first person as the named Audience, fresh look, not a checklist. That person can also be asked for lived feedback on any topic, comparison from their consideration set, what it would take to start or to refer, whether it is worth what it costs them, and what would make them leave. This role does not inhabit the user.
22
+ 4. **Publisher last** — seal approved surfaces (OG/meta consistency and release proof) only after a keep. Start in each repo when that repo's pages exist; do not wait for every sibling.
23
+
24
+ An engine gap or a missing check is a Foundry issue about the package that owns it. Never dump a consumer's strategy. Never name a consumer.
25
+
26
+ ## Handoff citations
27
+
28
+ Cite the strategist handoff: an audience id, approved strategist claim ids (`claim:<id>` in prose), applicable constraint ids (`constraint:<id>`), and the current direction id. Do not edit `clossys/strategist/`. Voice-glossary claims in this package are not strategist claims.
29
+
30
+ ## Pre-auth page
31
+
32
+ Done is exceptional (5) as defined in PRE-AUTH-QUALITY (the brief that ships with `@clossys/designer`). `designer-hero-css-check`, `designer-fold-check`, and `writer-check --live` prove 3 only — never call 3 done or world class. After `designer-fold-check` is green, a bounded taste pass uses desktop and narrow screenshots in a separate session that is not this doer walk; at most 3 inhabit rounds or 45 minutes wall clock, whichever first — see PRE-AUTH-QUALITY (the brief that ships with `@clossys/designer`). This walk does not self-certify exceptional keep. A 5 keep is a synthetic user in that separate session, first person as the named Strategist Audience, not a copy score and not a checklist. This role does not author keep-review evidence and does not inhabit the persona. Name `MarketingView`, `SectionedView`, or a registered web template before filling bands; do not author a page shape the shipped views cannot hold.
33
+
34
+ ## When this package is installed
35
+
36
+ If `node_modules/@clossys/writer` is present (or this package's bins are on PATH), use the exact pin in the tree. Read `package.json` `bin` for the real command names.
37
+ - Assessment CLI: `writer-rate-check`
38
+ - Additional gate CLI: `writer-check`
39
+
40
+ Summarize gate results in human language; keep machine kinds for tooling, not as the default reply.
41
+
42
+ ## When this package is not installed
43
+
44
+ You are here as a person in this repo the same way you are in every other inventoried repo.
45
+
46
+ - Intro and quick questions are always in scope.
47
+ - If this package's engine is not pinned in *this* tree, do not act and do not run a binary. Ask `@clossys-advisor` whether to hire you **in this repository**.
48
+ - Never say "I don't exist here," "open the hub to find me," or "this skill is missing from this folder."
49
+ - Never imply they should npm-install the whole catalogue.
@@ -157,8 +157,8 @@
157
157
  * position classified, and no inline user-facing prose found.
158
158
  */
159
159
 
160
- import { readdirSync, readFileSync, statSync } from "node:fs";
161
- import { extname, join, relative, sep } from "node:path";
160
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
161
+ import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
162
162
  import { extractCopyCandidates } from "./scan.js";
163
163
  import type { ParseFailure, SkippedFile } from "./scan.js";
164
164
  import {
@@ -182,6 +182,12 @@ export interface AddressabilityScanOptions {
182
182
  skipDirs?: string[];
183
183
  /** Same mechanism `scan.ts`'s `ScanOptions.pathExclusions` uses — see `path-exclusions.ts`. */
184
184
  pathExclusions?: PathExclusion[];
185
+ /**
186
+ * Consumer-declared persistent chrome (site header, footer, skip link, nav
187
+ * labels) — shell or layout files scanned in addition to the directory walk.
188
+ * Paths are relative to `root` unless absolute. Each file must exist.
189
+ */
190
+ chromeFiles?: string[];
185
191
  }
186
192
 
187
193
  const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
@@ -190,7 +196,7 @@ const DEFAULT_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "cov
190
196
  /** `.test.ts(x)`, `.spec.ts(x)`, `.check.ts(x)`, and bare `.d.ts` — the identical pattern `scan.ts` and `@example/ui`'s `style-scan.ts` both use, reused verbatim rather than reinvented (not exported by `scan.ts`, so redefined locally here — the same precedent `style-scan.ts` sets for its own copy of this constant). */
191
197
  const SKIP_FILE_RE = /\.(test|spec|check)\.(ts|tsx|js|jsx)$|\.d\.ts$/;
192
198
 
193
- export type AddressabilityPosition = "markup-text" | "user-facing-attribute";
199
+ export type AddressabilityPosition = "markup-text" | "user-facing-attribute" | "object-literal-copy";
194
200
 
195
201
  export interface AddressabilityViolation {
196
202
  file: string;
@@ -198,6 +204,10 @@ export interface AddressabilityViolation {
198
204
  position: AddressabilityPosition;
199
205
  /** Set only for `"user-facing-attribute"` — which of the four this literal was the value of. */
200
206
  attribute?: "aria-label" | "placeholder" | "alt" | "title";
207
+ /** Set only for `"object-literal-copy"` — the property key this literal was the value of. */
208
+ objectKey?: string;
209
+ /** Set only for `"object-literal-copy"` — dotted/bracket path when nested under `items`/`nav`/`links`. */
210
+ keyPath?: string;
201
211
  /** The literal's own raw source text, for a human auditing the finding. */
202
212
  raw: string;
203
213
  }
@@ -316,6 +326,12 @@ export function scanAddressabilitySources(root: string, options: AddressabilityS
316
326
 
317
327
  walk(root);
318
328
 
329
+ if (options.chromeFiles !== undefined) {
330
+ for (const chromePath of options.chromeFiles) {
331
+ scanAddressabilityChromeFile(root, chromePath, extensions, result);
332
+ }
333
+ }
334
+
319
335
  for (const exclusion of pathExclusionValidation.valid) {
320
336
  if ((matchCounts.get(exclusion) ?? 0) === 0) {
321
337
  result.pathExclusionFindings.push({
@@ -330,6 +346,62 @@ export function scanAddressabilitySources(root: string, options: AddressabilityS
330
346
  return result;
331
347
  }
332
348
 
349
+ function scanAddressabilityChromeFile(
350
+ root: string,
351
+ chromePath: string,
352
+ extensions: Set<string>,
353
+ result: AddressabilityScanResult,
354
+ ): void {
355
+ const full = isAbsolute(chromePath) ? resolve(chromePath) : resolve(root, chromePath);
356
+ if (!existsSync(full)) {
357
+ throw new Error(`scanAddressabilitySources: chrome file "${chromePath}" does not exist (resolved "${full}")`);
358
+ }
359
+ let stat;
360
+ try {
361
+ stat = statSync(full);
362
+ } catch (error) {
363
+ throw new Error(
364
+ `scanAddressabilitySources: cannot read chrome file "${chromePath}": ${error instanceof Error ? error.message : String(error)}`,
365
+ );
366
+ }
367
+ if (!stat.isFile()) {
368
+ throw new Error(`scanAddressabilitySources: chrome path "${chromePath}" is not a file`);
369
+ }
370
+
371
+ const relPath = relative(root, full).split(sep).join("/");
372
+ const displayPath = relPath.length > 0 && !relPath.startsWith("..") ? relPath : chromePath;
373
+ const entryName = full.slice(full.lastIndexOf(sep) + 1);
374
+
375
+ if (!extensions.has(extname(entryName).toLowerCase())) {
376
+ throw new Error(
377
+ `scanAddressabilitySources: chrome file "${chromePath}" has extension ${extname(entryName)} which is not in the scan extension set`,
378
+ );
379
+ }
380
+
381
+ if (SKIP_FILE_RE.test(entryName)) {
382
+ result.skippedByDesign.push({ file: displayPath, reason: "test-or-check-file" });
383
+ return;
384
+ }
385
+
386
+ let content: string;
387
+ try {
388
+ content = readFileSync(full, "utf8");
389
+ } catch (error) {
390
+ throw new Error(
391
+ `scanAddressabilitySources: cannot read chrome file "${full}": ${error instanceof Error ? error.message : String(error)}`,
392
+ );
393
+ }
394
+
395
+ const extracted = extractAddressabilityCandidates(content, displayPath);
396
+ if (extracted.parseFailure) {
397
+ result.parseFailures.push({ file: displayPath, detail: extracted.parseFailure });
398
+ return;
399
+ }
400
+ result.filesScanned++;
401
+ result.violations.push(...extracted.violations);
402
+ result.unchecked.push(...extracted.unchecked);
403
+ }
404
+
333
405
  // ------------------------------------------------------------- extraction
334
406
 
335
407
  /** `ariaLabel` -> `arialabel`, `aria-label` -> `arialabel` — the identical case/hyphen-insensitive normalization `scan.ts`'s own `normalizeIdentifier` performs (not exported, so reimplemented locally — a one-line heuristic, not worth a cross-file dependency). */
@@ -414,6 +486,83 @@ const DESTRUCTURING_OR_PARAMETER_DEFAULT_RE =
414
486
  * `DESTRUCTURING_OR_PARAMETER_DEFAULT_RE`'s own doc comment for exactly
415
487
  * what is and is not matched.
416
488
  */
489
+ /** Copy-bearing object-literal property keys (issue #1063) — normalized like `normalizeAttrName`. */
490
+ const COPY_BEARING_OBJECT_KEYS = new Set([
491
+ "label",
492
+ "title",
493
+ "cta",
494
+ "caption",
495
+ "heading",
496
+ "kicker",
497
+ "body",
498
+ "description",
499
+ "arialabel",
500
+ "text",
501
+ ]);
502
+
503
+ /** Non-copy object-literal keys — route targets, icons, tokens; not Writer surfaces. */
504
+ const ALLOWLISTED_OBJECT_LITERAL_KEYS = new Set([
505
+ "href",
506
+ "to",
507
+ "path",
508
+ "pathname",
509
+ "icon",
510
+ "iconname",
511
+ "slug",
512
+ "token",
513
+ "variant",
514
+ "size",
515
+ ]);
516
+
517
+ const OBJECT_LITERAL_CONTAINER_KEYS = ["items", "nav", "links"] as const;
518
+
519
+ const OBJECT_LITERAL_PROP_NAME_RE =
520
+ /(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][A-Za-z0-9_$-]*))\s*:\s*$/;
521
+
522
+ function literalStartIndex(content: string, line: number, raw: string): number | undefined {
523
+ const lines = content.split("\n");
524
+ const lineText = lines[line - 1];
525
+ if (lineText === undefined) return undefined;
526
+ const col = lineText.indexOf(raw);
527
+ if (col === -1) return undefined;
528
+ let start = 0;
529
+ for (let i = 0; i < line - 1; i++) start += lines[i]!.length + 1;
530
+ return start + col;
531
+ }
532
+
533
+ function objectLiteralPropertyName(content: string, start: number): string | undefined {
534
+ const from = Math.max(0, start - 120);
535
+ const before = content.slice(from, start);
536
+ const match = OBJECT_LITERAL_PROP_NAME_RE.exec(before);
537
+ if (!match) return undefined;
538
+ return match[1] ?? match[2] ?? match[3];
539
+ }
540
+
541
+ function objectLiteralKeyPath(content: string, start: number, propName: string): string {
542
+ const from = Math.max(0, start - 500);
543
+ const slice = content.slice(from, start);
544
+ for (const container of OBJECT_LITERAL_CONTAINER_KEYS) {
545
+ const re = new RegExp(`\\b${container}\\s*:\\s*\\[`, "g");
546
+ let lastIndex = -1;
547
+ let m: RegExpExecArray | null;
548
+ while ((m = re.exec(slice)) !== null) lastIndex = m.index;
549
+ if (lastIndex !== -1) return `${container}[].${propName}`;
550
+ }
551
+ return propName;
552
+ }
553
+
554
+ /** Route/icon-shaped literal values that are never copy, regardless of key. */
555
+ function looksLikeAllowlistedConstantValue(raw: string): boolean {
556
+ const quote = raw[0];
557
+ if (quote !== '"' && quote !== "'") return false;
558
+ const text = raw.slice(1, -1).trim();
559
+ if (text.length === 0) return false;
560
+ if (/^\/[^\s]*$/.test(text)) return true;
561
+ if (/^#[\w-]+$/.test(text)) return true;
562
+ if (/^https?:\/\//.test(text)) return true;
563
+ return false;
564
+ }
565
+
417
566
  function isDestructuringOrParameterDefault(lines: string[], line: number, raw: string): boolean {
418
567
  const lineText = lines[line - 1];
419
568
  if (lineText === undefined) return false;
@@ -575,13 +724,46 @@ export function extractAddressabilityCandidates(content: string, filePath: strin
575
724
  violations.push({ file: c.file, line: c.line, position: "user-facing-attribute", attribute, raw: c.raw });
576
725
  }
577
726
 
578
- // ---- position 3: everything else scan.ts kept as a real candidate —
727
+ // ---- position 3a: copy-bearing object-literal property values (issue
728
+ // #1063) — chrome config bags (`{ label: "...", href: "/join" }`) are
729
+ // Writer surfaces; allowlisted keys and route/icon-shaped values are not.
730
+ const objectLiteralClaimed = new Set<string>();
731
+ for (const c of candidates) {
732
+ if (c.kind !== "string") continue;
733
+ if (claimed.has(`${c.line}::${c.raw}`)) continue;
734
+ if (looksLikeUtilityClassList(c.raw)) continue;
735
+ const start = literalStartIndex(content, c.line, c.raw);
736
+ if (start === undefined) continue;
737
+ const propName = objectLiteralPropertyName(content, start);
738
+ if (propName === undefined) continue;
739
+ const normalizedProp = normalizeAttrName(propName);
740
+ if (ALLOWLISTED_OBJECT_LITERAL_KEYS.has(normalizedProp) || looksLikeAllowlistedConstantValue(c.raw)) {
741
+ objectLiteralClaimed.add(`${c.line}::${c.raw}`);
742
+ continue;
743
+ }
744
+ if (!COPY_BEARING_OBJECT_KEYS.has(normalizedProp)) continue;
745
+ if (isDestructuringOrParameterDefault(lines, c.line, c.raw)) continue;
746
+ if (!hasProse(c.raw)) continue;
747
+ const keyPath = objectLiteralKeyPath(content, start, propName);
748
+ objectLiteralClaimed.add(`${c.line}::${c.raw}`);
749
+ violations.push({
750
+ file: c.file,
751
+ line: c.line,
752
+ position: "object-literal-copy",
753
+ objectKey: propName,
754
+ keyPath,
755
+ raw: c.raw,
756
+ });
757
+ }
758
+
759
+ // ---- position 3b: everything else scan.ts kept as a real candidate —
579
760
  // never silently treated as clean, never treated as a violation either
580
761
  // — except a plain string shaped like a utility class LIST, which is
581
762
  // definitively not prose (see looksLikeUtilityClassList).
582
763
  for (const c of candidates) {
583
764
  if (c.kind === "jsx-text") continue; // already position 1
584
765
  if (c.kind === "string" && claimed.has(`${c.line}::${c.raw}`)) continue; // already position 2
766
+ if (c.kind === "string" && objectLiteralClaimed.has(`${c.line}::${c.raw}`)) continue;
585
767
  if (c.kind === "string" && looksLikeUtilityClassList(c.raw)) continue; // a class-name list, not prose
586
768
  unchecked.push({
587
769
  file: c.file,