srclawyer 0.6.0 → 0.6.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/dist/cli.js CHANGED
@@ -16,9 +16,51 @@ program
16
16
  .version(version);
17
17
  program
18
18
  .command("init")
19
- .description("One-time setup: jurisdiction, markets, industry")
20
- .action(async () => {
21
- await runInit(process.cwd());
19
+ .description("One-time setup: jurisdiction, markets, industry, and optional policy details (company info, data practices, regional rights)")
20
+ .option("--policy-details", "ask the optional policy-detail questions (company info, data practices, regional rights)")
21
+ .option("--no-policy-details", "skip all optional policy-detail questions, leaving them unanswered")
22
+ .option("--collects-childrens-data", "declare that this product knowingly collects data from children under 13/16")
23
+ .option("--no-collects-childrens-data", "declare that this product does not knowingly collect data from children under 13/16")
24
+ .option("--company-name <name>", "company or product name")
25
+ .option("--site-url <url>", "site or app URL")
26
+ .option("--privacy-contact-email <email>", "privacy contact email address")
27
+ .option("--eu-uk-users", "declare that this product has users in the EU/UK")
28
+ .option("--no-eu-uk-users", "declare that this product has no users in the EU/UK")
29
+ .option("--california-users", "declare that this product has users in California")
30
+ .option("--no-california-users", "declare that this product has no users in California")
31
+ .option("--sells-or-shares-data", "declare that personal data is sold or shared with third parties")
32
+ .option("--no-sells-or-shares-data", "declare that personal data is not sold or shared with third parties")
33
+ .option("--uses-data-for-advertising", "declare that personal data is used for advertising")
34
+ .option("--no-uses-data-for-advertising", "declare that personal data is not used for advertising")
35
+ .option("--data-retention-policy <text>", "how long personal data is retained")
36
+ .option("--account-deletion-instructions <text>", "how a user can delete their account or request data deletion")
37
+ .option("--minimum-age <number>", "minimum age required to use this product")
38
+ .option("--sends-data-to-ai-services", "declare that user data is sent to third-party AI services")
39
+ .option("--no-sends-data-to-ai-services", "declare that user data is not sent to third-party AI services")
40
+ .action(async (opts) => {
41
+ let minimumAge;
42
+ if (opts.minimumAge !== undefined) {
43
+ const parsed = Number(opts.minimumAge);
44
+ if (!Number.isInteger(parsed) || parsed < 0) {
45
+ program.error(`error: --minimum-age must be a whole, non-negative number (got "${opts.minimumAge}")`);
46
+ }
47
+ minimumAge = parsed;
48
+ }
49
+ await runInit(process.cwd(), {
50
+ policyDetails: opts.policyDetails,
51
+ collectsChildrensData: opts.collectsChildrensData,
52
+ companyName: opts.companyName,
53
+ siteUrl: opts.siteUrl,
54
+ privacyContactEmail: opts.privacyContactEmail,
55
+ euUkUsers: opts.euUkUsers,
56
+ californiaUsers: opts.californiaUsers,
57
+ sellsOrSharesData: opts.sellsOrSharesData,
58
+ usesDataForAdvertising: opts.usesDataForAdvertising,
59
+ dataRetentionPolicy: opts.dataRetentionPolicy,
60
+ accountDeletionInstructions: opts.accountDeletionInstructions,
61
+ minimumAge,
62
+ sendsDataToAiServices: opts.sendsDataToAiServices,
63
+ });
22
64
  });
23
65
  program
24
66
  .command("scan")
@@ -1 +1,16 @@
1
- export declare function runInit(root: string): Promise<void>;
1
+ export interface InitFlags {
2
+ policyDetails?: boolean;
3
+ collectsChildrensData?: boolean;
4
+ companyName?: string;
5
+ siteUrl?: string;
6
+ privacyContactEmail?: string;
7
+ euUkUsers?: boolean;
8
+ californiaUsers?: boolean;
9
+ sellsOrSharesData?: boolean;
10
+ usesDataForAdvertising?: boolean;
11
+ dataRetentionPolicy?: string;
12
+ accountDeletionInstructions?: string;
13
+ minimumAge?: number;
14
+ sendsDataToAiServices?: boolean;
15
+ }
16
+ export declare function runInit(root: string, flags?: InitFlags): Promise<void>;
@@ -24,7 +24,62 @@ async function ask(lines, question, fallback) {
24
24
  const answer = done ? "" : value.trim();
25
25
  return answer.length > 0 ? answer : fallback;
26
26
  }
27
- export async function runInit(root) {
27
+ async function askOptional(lines, question) {
28
+ process.stdout.write(`${question} (optional, press Enter to skip): `);
29
+ const { value, done } = await lines.next();
30
+ const answer = done ? "" : value.trim();
31
+ return answer.length > 0 ? answer : null;
32
+ }
33
+ // suggested, when not null, is shown as the bracketed default and adopted on a blank answer -- used
34
+ // to prefill EU/UK and California questions from targetMarkets without ever forcing an answer. When
35
+ // suggested is null (no signal), blank means genuinely unanswered, same as every other optional field.
36
+ async function askYesNoOptional(lines, question, suggested = null) {
37
+ const suffix = suggested === null ? " (y/n, optional, press Enter to skip)" : ` [${suggested ? "y" : "n"}]`;
38
+ process.stdout.write(`${question}${suffix}: `);
39
+ const { value, done } = await lines.next();
40
+ const answer = done ? "" : value.trim().toLowerCase();
41
+ if (answer.length === 0)
42
+ return suggested;
43
+ return /^y(es)?$/.test(answer);
44
+ }
45
+ async function askOptionalAge(lines, question) {
46
+ const raw = await askOptional(lines, question);
47
+ if (raw === null)
48
+ return null;
49
+ const parsed = Number(raw);
50
+ if (!Number.isInteger(parsed) || parsed < 0) {
51
+ console.log(` (Could not read "${raw}" as a whole, non-negative number -- skipping. You can set this later by editing ${CONFIG_FILENAME}.)`);
52
+ return null;
53
+ }
54
+ return parsed;
55
+ }
56
+ // Generic resolution order for every new optional field: an explicit flag always wins outright;
57
+ // otherwise the field is only asked if the policy-details gate resolved to yes; otherwise it's null.
58
+ async function resolveField(flagValue, gateYes, askFn) {
59
+ if (flagValue !== undefined)
60
+ return flagValue;
61
+ if (!gateYes)
62
+ return null;
63
+ return askFn();
64
+ }
65
+ const EU_UK_TOKENS = new Set(["eu", "uk", "gb", "eea", "european union", "united kingdom"]);
66
+ const CALIFORNIA_TOKENS = new Set(["ca", "california", "us-ca"]);
67
+ function targetMarketsSuggestEuUk(markets) {
68
+ return markets.some((m) => EU_UK_TOKENS.has(m.trim().toLowerCase()));
69
+ }
70
+ function targetMarketsSuggestCalifornia(markets) {
71
+ return markets.some((m) => CALIFORNIA_TOKENS.has(m.trim().toLowerCase()));
72
+ }
73
+ // Only warns in the one direction that's a real contradiction: target markets signal a region, but
74
+ // the final answer explicitly denies it. The reverse (no signal, but the user says yes) isn't a
75
+ // contradiction -- targetMarkets is free text and was never meant to be an exhaustive jurisdiction
76
+ // list. Never blocks or overrides -- the explicit answer always stands, this only warns.
77
+ function warnIfContradictsTargetMarkets(label, finalValue, suggestedFromMarkets) {
78
+ if (suggestedFromMarkets && finalValue === false) {
79
+ console.log(`\nWarning: your target markets suggest ${label} users, but you indicated none -- the generated policy will omit ${label}-specific disclosures. Re-run init or edit ${CONFIG_FILENAME} if this is incorrect.\n`);
80
+ }
81
+ }
82
+ export async function runInit(root, flags = {}) {
28
83
  const existingPath = configPath(root);
29
84
  if (existsSync(existingPath)) {
30
85
  console.log(`${CONFIG_FILENAME} already exists. Delete it first if you want to redo setup.`);
@@ -43,37 +98,66 @@ export async function runInit(root) {
43
98
  console.log("SrcLawyer setup — answered once, reused on every scan.\n");
44
99
  const entityLocation = await ask(lines, "Where is your legal entity located? (country)", DEFAULT_CONFIG.entityLocation ?? "");
45
100
  const targetMarketsRaw = await ask(lines, "Which regions/markets do you serve? (comma-separated, e.g. US, EU)", "");
101
+ const targetMarkets = targetMarketsRaw.split(",").map((s) => s.trim()).filter(Boolean);
46
102
  const industry = await ask(lines, "What industry/vertical is this product in?", guessedIndustry ?? "");
47
- const childrensDataRaw = await ask(lines, "Does this product knowingly collect data from children under 13/16?", "no");
103
+ const gateYes = flags.policyDetails !== undefined
104
+ ? flags.policyDetails
105
+ : /^y(es)?$/i.test((await ask(lines, "Add privacy policy details now? (company info, data practices, regional rights, etc.)", "y")).trim());
106
+ // Deliberately requires an explicit y/n -- a bare Enter, empty stdin, or a declined gate must leave
107
+ // this null (unanswered, rendered as a placeholder), never silently "no". Unlike askOptional's other
108
+ // callers, there is no safe default here: "no" is itself a real, consequential legal declaration, not
109
+ // a neutral fallback. Layer 2's high-stakes override (scan.ts) coerces null to false at its one call
110
+ // site, so leaving this unanswered never weakens that safety check -- see childrensDataOverride.test.ts.
111
+ const collectsChildrensData = await resolveField(flags.collectsChildrensData, gateYes, () => askYesNoOptional(lines, "Does this product knowingly collect data from children under 13/16?"));
112
+ const minimumAge = await resolveField(flags.minimumAge, gateYes, () => askOptionalAge(lines, "Minimum age required to use this product"));
113
+ const companyName = await resolveField(flags.companyName, gateYes, () => askOptional(lines, "Company or product name"));
114
+ const siteUrl = await resolveField(flags.siteUrl, gateYes, () => askOptional(lines, "Site or app URL"));
115
+ const privacyContactEmail = await resolveField(flags.privacyContactEmail, gateYes, () => askOptional(lines, "Privacy contact email"));
116
+ const euUkSuggested = targetMarketsSuggestEuUk(targetMarkets);
117
+ const hasEuUkUsers = await resolveField(flags.euUkUsers, gateYes, () => askYesNoOptional(lines, "Do you have users in the EU/UK?", euUkSuggested ? true : null));
118
+ warnIfContradictsTargetMarkets("EU/UK", hasEuUkUsers, euUkSuggested);
119
+ const californiaSuggested = targetMarketsSuggestCalifornia(targetMarkets);
120
+ const hasCaliforniaUsers = await resolveField(flags.californiaUsers, gateYes, () => askYesNoOptional(lines, "Do you have users in California?", californiaSuggested ? true : null));
121
+ warnIfContradictsTargetMarkets("California", hasCaliforniaUsers, californiaSuggested);
122
+ const sellsOrSharesData = await resolveField(flags.sellsOrSharesData, gateYes, () => askYesNoOptional(lines, "Does this product sell or share personal data with third parties?"));
123
+ const usesDataForAdvertising = await resolveField(flags.usesDataForAdvertising, gateYes, () => askYesNoOptional(lines, "Does this product use personal data for advertising?"));
124
+ const dataRetentionPolicy = await resolveField(flags.dataRetentionPolicy, gateYes, () => askOptional(lines, "How long is personal data retained?"));
125
+ const accountDeletionInstructions = await resolveField(flags.accountDeletionInstructions, gateYes, () => askOptional(lines, "How can a user delete their account or request data deletion?"));
126
+ // Deliberately about the SCANNED PRODUCT's own behavior (does it send its end-users' data to AI
127
+ // services as part of its own functionality) -- not about SrcLawyer's own, separately-documented
128
+ // BYOK network calls described in NETWORK_CALLS_NOTICE above.
129
+ const sendsDataToAiServices = await resolveField(flags.sendsDataToAiServices, gateYes, () => askYesNoOptional(lines, "Does this product send user data to third-party AI services (e.g. OpenAI, Anthropic) as part of its own functionality?"));
48
130
  // "managed" is deliberately not offered here -- it's a real SrcLawyerTier value and config.ts/
49
131
  // resolveProviderForTier.ts both still support it fully (a hand-edited config with tier: managed
50
132
  // still gets a clear, accurate error at scan time), but there's no advertised path to it from this
51
133
  // prompt, so a tester reading the choices here can't select a dead end during setup.
52
- const tierRaw = await ask(lines, "Tier: free (static analysis only) / byok (bring your own Anthropic key)", "free");
53
- const tier = normalizeTier(tierRaw);
134
+ const enableByokRaw = await ask(lines, "Enable optional AI-assisted review with your own Anthropic key? (advanced)", "n");
135
+ const tier = /^y(es)?$/i.test(enableByokRaw.trim()) ? "byok" : "free";
54
136
  let apiKeyEnvVar = null;
55
137
  if (tier === "byok") {
56
138
  apiKeyEnvVar = await ask(lines, "Which environment variable holds your Anthropic API key?", "ANTHROPIC_API_KEY");
57
139
  console.log(`\nNote: only the name "${apiKeyEnvVar}" is written to ${CONFIG_FILENAME} — the key itself is never written to disk. It's read from your environment at scan time.`);
58
140
  }
59
- else if (tier === "managed") {
60
- console.log("\nHeads up: the managed tier's hosted backend isn't available yet. Layer 1 (static analysis) will still run fine; AI-assisted resolution will report a clear error until it ships.");
61
- }
62
141
  rl.close();
63
142
  const config = {
64
143
  entityLocation: entityLocation || null,
65
- targetMarkets: targetMarketsRaw.split(",").map((s) => s.trim()).filter(Boolean),
144
+ targetMarkets,
66
145
  industry: industry || null,
67
- collectsChildrensData: /^y(es)?$/i.test(childrensDataRaw.trim()),
146
+ collectsChildrensData,
68
147
  tier,
69
148
  ...(tier === "byok" ? { llm: { provider: "anthropic", apiKeyEnvVar } } : {}),
149
+ companyName,
150
+ siteUrl,
151
+ privacyContactEmail,
152
+ hasEuUkUsers,
153
+ hasCaliforniaUsers,
154
+ sellsOrSharesData,
155
+ usesDataForAdvertising,
156
+ dataRetentionPolicy,
157
+ accountDeletionInstructions,
158
+ minimumAge,
159
+ sendsDataToAiServices,
70
160
  };
71
161
  writeConfig(root, config);
72
162
  console.log(`\nWrote ${CONFIG_FILENAME}. Run "srclawyer scan" to analyze this codebase.`);
73
163
  }
74
- function normalizeTier(raw) {
75
- const normalized = raw.trim().toLowerCase();
76
- if (normalized === "byok" || normalized === "managed")
77
- return normalized;
78
- return "free";
79
- }
@@ -56,7 +56,11 @@ export async function runScan(root, options) {
56
56
  }
57
57
  console.log(`Sending ${ambiguous.length} ambiguous item(s) to ${provider.name}...`);
58
58
  const resolved = await resolveAmbiguousFindings(ambiguous, provider, {
59
- collectsChildrensData: config.collectsChildrensData,
59
+ // Preserves today's exact override behavior bit-for-bit: a missing/legacy key already
60
+ // behaved as false before collectsChildrensData became nullable. Only the generated
61
+ // document (generatePolicy.ts) distinguishes "explicitly no" from "never answered" -- this
62
+ // safety-critical override is unchanged.
63
+ collectsChildrensData: config.collectsChildrensData ?? false,
60
64
  });
61
65
  const resolvedById = new Map(resolved.map((f) => [f.id, f]));
62
66
  findings = findings.map((f) => resolvedById.get(f.id) ?? f);
@@ -10,9 +10,20 @@ export interface SrcLawyerConfig {
10
10
  entityLocation: string | null;
11
11
  targetMarkets: string[];
12
12
  industry: string | null;
13
- collectsChildrensData: boolean;
13
+ collectsChildrensData: boolean | null;
14
14
  tier?: SrcLawyerTier;
15
15
  llm?: SrcLawyerLlmConfig;
16
+ companyName?: string | null;
17
+ siteUrl?: string | null;
18
+ privacyContactEmail?: string | null;
19
+ hasEuUkUsers?: boolean | null;
20
+ hasCaliforniaUsers?: boolean | null;
21
+ sellsOrSharesData?: boolean | null;
22
+ usesDataForAdvertising?: boolean | null;
23
+ dataRetentionPolicy?: string | null;
24
+ accountDeletionInstructions?: string | null;
25
+ minimumAge?: number | null;
26
+ sendsDataToAiServices?: boolean | null;
16
27
  }
17
28
  export declare const DEFAULT_CONFIG: SrcLawyerConfig;
18
29
  export declare function configPath(root: string): string;
@@ -8,8 +8,19 @@ export const DEFAULT_CONFIG = {
8
8
  entityLocation: null,
9
9
  targetMarkets: [],
10
10
  industry: null,
11
- collectsChildrensData: false,
11
+ collectsChildrensData: null,
12
12
  tier: "free",
13
+ companyName: null,
14
+ siteUrl: null,
15
+ privacyContactEmail: null,
16
+ hasEuUkUsers: null,
17
+ hasCaliforniaUsers: null,
18
+ sellsOrSharesData: null,
19
+ usesDataForAdvertising: null,
20
+ dataRetentionPolicy: null,
21
+ accountDeletionInstructions: null,
22
+ minimumAge: null,
23
+ sendsDataToAiServices: null,
13
24
  };
14
25
  export function configPath(root) {
15
26
  return join(root, CONFIG_FILENAME);
@@ -96,7 +96,7 @@ function renderContextSection(config) {
96
96
  lines.push(`- **Entity location:** ${config.entityLocation ?? "not specified"}`);
97
97
  lines.push(`- **Target markets:** ${config.targetMarkets.length > 0 ? config.targetMarkets.join(", ") : "not specified"}`);
98
98
  lines.push(`- **Industry:** ${config.industry ?? "not specified"}`);
99
- lines.push(`- **Collects children's data:** ${config.collectsChildrensData ? "yes" : "no"}`);
99
+ lines.push(`- **Collects children's data:** ${config.collectsChildrensData === null || config.collectsChildrensData === undefined ? "not specified" : config.collectsChildrensData ? "yes" : "no"}`);
100
100
  return lines.join("\n");
101
101
  }
102
102
  function renderUnsupportedStackWarning(warning) {
@@ -108,6 +108,110 @@ function renderUnsupportedStackWarning(warning) {
108
108
  function renderProtectedLogicWarning(warning) {
109
109
  return ["> **WARNING: PROTECTED-LOGIC SERVICE UNAVAILABLE — POLICY MAY BE INCOMPLETE**", ">", `> ${warning}`].join("\n");
110
110
  }
111
+ function placeholder(label) {
112
+ return `[TO COMPLETE: ${label}]`;
113
+ }
114
+ // The one function permitted to record that a fact was never answered -- every new section below
115
+ // goes through this (directly or via valueOrPlaceholder/triStateSentence) so renderPlaceholderSummary
116
+ // can list everything that's missing in one place, without a second pass over the config.
117
+ function placeholderLine(label, placeholders) {
118
+ placeholders.push(label);
119
+ return placeholder(label);
120
+ }
121
+ function valueOrPlaceholder(value, label, placeholders) {
122
+ if (value === null || value === undefined || value === "")
123
+ return placeholderLine(label, placeholders);
124
+ return value;
125
+ }
126
+ // Avoids a double terminal punctuation mark when a user-supplied value already ends with one --
127
+ // e.g. a company name ending in "." ("Acme Inc.") or a URL ending in "/" (where appending "." right
128
+ // after the slash reads as part of the path). The placeholder marker itself ends with "]", so this
129
+ // never double-punctuates that either.
130
+ function endSentence(prefix, value) {
131
+ const trimmed = value.trim();
132
+ return /[.!?/\]]$/.test(trimmed) ? `${prefix}${trimmed}` : `${prefix}${trimmed}.`;
133
+ }
134
+ function triStateSentence(value, label, trueText, falseText, placeholders) {
135
+ if (value === true)
136
+ return trueText;
137
+ if (value === false)
138
+ return falseText;
139
+ return placeholderLine(label, placeholders);
140
+ }
141
+ function renderIdentitySection(config, placeholders) {
142
+ const companyName = valueOrPlaceholder(config?.companyName, "company or product name", placeholders);
143
+ const siteUrl = valueOrPlaceholder(config?.siteUrl, "site or app URL", placeholders);
144
+ const privacyContactEmail = valueOrPlaceholder(config?.privacyContactEmail, "privacy contact email address", placeholders);
145
+ return [
146
+ "## About This Policy",
147
+ "",
148
+ endSentence("This policy is issued by ", companyName),
149
+ endSentence("It applies to the product or service available at ", siteUrl),
150
+ endSentence("Questions about this policy or requests regarding your personal data can be sent to ", privacyContactEmail),
151
+ ].join("\n");
152
+ }
153
+ // The sell/share and advertising sentences are the highest-risk new text in this document: the policy
154
+ // can simultaneously list detected third-party integrations (e.g. Stripe) in the Third-Party Data
155
+ // Processors section above. These sentences deliberately (1) use CCPA/CPRA's own defined terms rather
156
+ // than casual language, (2) frame the statement as the user's own self-declared setup answer, not a
157
+ // legal conclusion drawn by this tool, and (3) always close with an explicit cross-reference back to
158
+ // the detected-processors section, in all three states (true/false/placeholder), so the two sections
159
+ // are never read in isolation from each other. See docs/legal-wording-review.md -- this is drafted,
160
+ // attorney-reviewable starting wording, not settled language.
161
+ function renderDataPracticesSection(config, placeholders) {
162
+ const retention = valueOrPlaceholder(config?.dataRetentionPolicy, "data retention period or policy", placeholders);
163
+ const deletion = valueOrPlaceholder(config?.accountDeletionInstructions, "how a user can delete their account or request data deletion", placeholders);
164
+ const sellShare = triStateSentence(config?.sellsOrSharesData, 'whether personal data is sold or shared with third parties, as "sell" and "share" are defined under applicable law (e.g. CCPA/CPRA)', 'Based on information provided during setup, this product has been declared to sell or share personal data with third parties, as "sell" and "share" are defined under applicable law (e.g. CCPA/CPRA).', 'Based on information provided during setup, this product has not been declared to sell or share personal data with third parties, as "sell" and "share" are defined under applicable law (e.g. CCPA/CPRA).', placeholders);
165
+ const advertising = triStateSentence(config?.usesDataForAdvertising, "whether personal data is used for cross-context behavioral advertising", "Based on information provided during setup, this product has been declared to use personal data for cross-context behavioral advertising.", "Based on information provided during setup, this product has not been declared to use personal data for cross-context behavioral advertising.", placeholders);
166
+ const aiServices = triStateSentence(config?.sendsDataToAiServices, "whether user data is sent to third-party AI services as part of this product's own functionality", "User data may be sent to third-party AI services as part of this product's own functionality.", "User data is not sent to third-party AI services as part of this product's own functionality.", placeholders);
167
+ return [
168
+ "## Data Retention, Deletion, and Use",
169
+ "",
170
+ `Personal data is retained as follows: ${retention}`,
171
+ `To delete your account or request deletion of your personal data: ${deletion}`,
172
+ sellShare,
173
+ advertising,
174
+ "See the Third-Party Data Processors section above for the specific third-party integrations detected in this codebase; the declarations above describe this product's own stated practices and do not override what was actually detected there.",
175
+ aiServices,
176
+ ].join("\n");
177
+ }
178
+ function renderRightsSection(config, placeholders) {
179
+ const euUk = config?.hasEuUkUsers;
180
+ const california = config?.hasCaliforniaUsers;
181
+ if (euUk === false && california === false) {
182
+ return ["## Your Rights", "", "This product has not declared users in jurisdictions with additional statutory privacy rights (EU/UK, California)."].join("\n");
183
+ }
184
+ const lines = ["## Your Rights", ""];
185
+ if (euUk === true) {
186
+ lines.push("If you are located in the EU or UK, you may have rights under the GDPR and/or UK GDPR, including the right to access, correct, delete, or restrict the use of your personal data.");
187
+ }
188
+ else if (euUk !== false) {
189
+ lines.push(placeholderLine("whether this product has users in the EU/UK, which determines whether GDPR/UK GDPR disclosures apply", placeholders));
190
+ }
191
+ if (california === true) {
192
+ lines.push("If you are a California resident, you may have rights under the CCPA/CPRA, including the right to know, delete, correct, and opt out of the sale or sharing of your personal data.");
193
+ }
194
+ else if (california !== false) {
195
+ lines.push(placeholderLine("whether this product has users in California, which determines whether CCPA/CPRA disclosures apply", placeholders));
196
+ }
197
+ return lines.join("\n");
198
+ }
199
+ function renderChildrensPrivacySection(config, placeholders) {
200
+ const collects = config?.collectsChildrensData;
201
+ const collectsLine = triStateSentence(collects, "whether this product knowingly collects personal data from children", "This product knowingly collects personal data from children under applicable age thresholds (e.g. under 13 in the US, under 16 in the EU where applicable).", "This product does not knowingly collect personal data from children under applicable age thresholds.", placeholders);
202
+ const minimumAge = config?.minimumAge;
203
+ const ageLine = minimumAge === null || minimumAge === undefined
204
+ ? placeholderLine("minimum age required to use this product, if any", placeholders)
205
+ : `Users must be at least ${minimumAge} years old to use this product.`;
206
+ return ["## Children's Privacy", "", collectsLine, ageLine].join("\n");
207
+ }
208
+ function renderPlaceholderSummary(labels) {
209
+ const lines = ["## Placeholders — Complete Before Publishing", ""];
210
+ for (const label of labels) {
211
+ lines.push(`- ${placeholder(label)}`);
212
+ }
213
+ return lines.join("\n");
214
+ }
111
215
  function outcomeOf(finding) {
112
216
  if (!finding.requiresReview)
113
217
  return "confident";
@@ -124,6 +228,13 @@ export function generatePolicyMarkdown(result, config, llmError, protectedLogicW
124
228
  });
125
229
  const declinedFindings = result.findings.filter((f) => outcomeOf(f) === "declined");
126
230
  const reviewFindings = result.findings.filter((f) => outcomeOf(f) === "unresolved");
231
+ // Rendered first (before the final parts[] is assembled) so the shared placeholders array is fully
232
+ // populated in time for the top-of-document summary below.
233
+ const placeholders = [];
234
+ const identitySection = renderIdentitySection(config, placeholders);
235
+ const dataPracticesSection = renderDataPracticesSection(config, placeholders);
236
+ const rightsSection = renderRightsSection(config, placeholders);
237
+ const childrensPrivacySection = renderChildrensPrivacySection(config, placeholders);
127
238
  const parts = ["# Privacy Policy", ""];
128
239
  if (result.unsupportedStackWarning) {
129
240
  parts.push(renderUnsupportedStackWarning(result.unsupportedStackWarning), "");
@@ -134,7 +245,10 @@ export function generatePolicyMarkdown(result, config, llmError, protectedLogicW
134
245
  if (llmError) {
135
246
  parts.push(renderLlmErrorWarning(llmError), "");
136
247
  }
137
- parts.push(`_Generated by SrcLawyer from static analysis of this codebase (${result.filesScanned} file(s) scanned)._`, "", renderContextSection(config), "", "## Data We Collect", "");
248
+ if (placeholders.length > 0) {
249
+ parts.push(renderPlaceholderSummary([...new Set(placeholders)]), "");
250
+ }
251
+ parts.push(`_Generated by SrcLawyer from static analysis of this codebase (${result.filesScanned} file(s) scanned)._`, "", renderContextSection(config), "", identitySection, "", "## Data We Collect", "");
138
252
  const categorySections = renderCategorySections(confidentFindings);
139
253
  if (categorySections.length > 0) {
140
254
  parts.push(categorySections.join("\n\n"));
@@ -149,6 +263,7 @@ export function generatePolicyMarkdown(result, config, llmError, protectedLogicW
149
263
  if (processorSection) {
150
264
  parts.push("", processorSection);
151
265
  }
266
+ parts.push("", dataPracticesSection, "", rightsSection, "", childrensPrivacySection);
152
267
  const declinedSection = renderDeclinedSection(declinedFindings);
153
268
  if (declinedSection) {
154
269
  parts.push("", declinedSection);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srclawyer",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Static analysis engine that derives privacy policy documentation directly from source code",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",