srclawyer 0.6.3 → 0.8.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.
package/README.md CHANGED
@@ -23,10 +23,44 @@ srclawyer init
23
23
  srclawyer scan # analyze the codebase and write PRIVACY_POLICY.md
24
24
  srclawyer scan --json # findings as JSON (still writes the policy file)
25
25
  srclawyer scan --out docs/privacy.md
26
+ srclawyer scan --force # overwrite the policy even if it was edited (see below)
26
27
  ```
27
28
 
28
29
  If `srclawyer` isn't found right after a global install, see [Troubleshooting: command not found](#troubleshooting-command-not-found) below.
29
30
 
31
+ ## Editing the generated policy
32
+
33
+ `scan` regenerates `PRIVACY_POLICY.md` freely **until you edit it**. After that the file is yours, and `scan` will not overwrite it.
34
+
35
+ The generated policy has `[TO COMPLETE: …]` placeholders for facts only you know (company name, contact email, retention period, and so on). Fill them in by answering the `init` questions or by setting the same fields in `.srclawyer.config.yml`, then re-run `scan`: the placeholders are replaced. Once you have changed the file by hand (filled a placeholder in the text, reworded a section, anything), it is no longer the generated version, so complete the rest by hand too.
36
+
37
+ How it works: every generated policy ends with a marker line, `<!-- srclawyer:policy-sha256=… -->`, holding a hash of the rest of the file. Before writing, `scan` recomputes the hash.
38
+
39
+ | The existing file | What `scan` does |
40
+ |---|---|
41
+ | doesn't exist, or still matches its marker | writes the new policy |
42
+ | edited since it was generated | refuses (see below) |
43
+ | has no marker | refuses (see below) |
44
+ | any of the above, with `--force` | overwrites it |
45
+
46
+ When it refuses, a plain `scan` exits 2 before scanning anything, and prints one of these:
47
+
48
+ ```text
49
+ Not overwriting PRIVACY_POLICY.md: it has been edited since SrcLawyer generated it. Write the new policy to a separate file with --out PRIVACY_POLICY.generated.md and compare, or pass --force to replace it.
50
+ ```
51
+
52
+ ```text
53
+ Not overwriting PRIVACY_POLICY.md: it was not generated by this version of SrcLawyer (it may be hand-written, or generated before overwrite protection was added in 0.8.0). Write the new policy to a separate file with --out PRIVACY_POLICY.generated.md and compare, or pass --force to replace it.
54
+ ```
55
+
56
+ - **`--out` is guarded the same way.** `srclawyer scan --out PRIVACY_POLICY.generated.md` writes a draft you can compare with your edited policy; running it again overwrites that draft cleanly as long as you haven't edited the draft.
57
+ - **`scan --write-inventory`** still writes the drift baseline when your policy is protected. It leaves the policy untouched, says so, and exits 0. So "update the policy by hand, then refresh the baseline" works without `--force`.
58
+ - **`init`** never overwrites a policy. If `PRIVACY_POLICY.md` is already there and would be refused, it skips its "run a scan now?" question and prints the `--out` command instead.
59
+ - **The marker is an HTML comment.** It is invisible when the Markdown is rendered, but it is in the file's raw text and in the page source if you publish the Markdown as is. You can delete the line, and nothing breaks, but the file then counts as not generated by SrcLawyer and is protected from then on.
60
+ - **Formatters count as edits.** If a formatter (Prettier, a Markdown auto-fix hook) rewrites the file, it no longer matches its marker. `scan` fails safe: it refuses, and you use `--out` or `--force`.
61
+
62
+ **Upgrading from 0.7.0 or earlier.** Policies generated before 0.8.0 have no marker, so the first `scan` after upgrading refuses to overwrite one, even if you never edited it. If you never touched it, run `srclawyer scan --force` once; from then on it carries a marker. If you did edit it, run `srclawyer scan --out PRIVACY_POLICY.generated.md` and compare the two files.
63
+
30
64
  ## Development
31
65
 
32
66
  Working on SrcLawyer itself (not just using it):
@@ -117,6 +151,51 @@ llm:
117
151
 
118
152
  Only the variable's *name* is stored in the config — the key itself is never written to disk. When it's enabled, `scan` prints a notice that redacted snippets are being sent to Anthropic; `scan --no-llm` skips it for a single run. A hosted tier, where SrcLawyer supplies the model access and no key is needed, is planned but not yet available: a config with `tier: managed` gets a clear "not available yet" error rather than silently doing nothing.
119
153
 
154
+ ## Drift check (GitHub Action)
155
+
156
+ **Free during the beta.** Flags when your code starts collecting a new kind of data, or using a new third-party processor, that your privacy policy was never updated for. It compares the code against a committed baseline, `.srclawyer.inventory.json` — just the sorted data categories and processors, no file names or line numbers — not against policy text, which people edit by hand.
157
+
158
+ ```bash
159
+ npx srclawyer scan --write-inventory # once: writes the policy (if safe to) and the baseline; commit the baseline
160
+ npx srclawyer check # exit 0 no drift, 1 drift, 2 error, 3 inconclusive
161
+ ```
162
+
163
+ ```yaml
164
+ - uses: actions/checkout@v7
165
+ - uses: SrcLawyer/srclawyer@v0.8.0
166
+ with:
167
+ srclawyer-version: 0.8.0 # required and pinned
168
+ ```
169
+
170
+ Drift fails the check, and the step summary lists what was added and removed with a one-line fix. If no verdict can be reached — the confidence service is unreachable, files were skipped, or the baseline and the run used different file discovery — the result is **inconclusive**, a warning by default, and is never reported as drift. The Action needs no secrets and `contents: read`; an optional pull-request comment needs `pull-requests: write`. Inside a git repository, `check` and `--write-inventory` look only at tracked and untracked-but-not-ignored files, so a gitignored build directory can't cause phantom drift; plain `scan` is unchanged. `check` makes the same network calls as `scan` (below) and never calls an AI provider.
171
+
172
+ | Exit code | `srclawyer check` | `srclawyer scan` |
173
+ |---|---|---|
174
+ | 0 | no drift | clean |
175
+ | 1 | drift | completed with warnings |
176
+ | 2 | crash, bad usage, or a missing or invalid baseline | crash or bad usage |
177
+ | 3 | inconclusive | — |
178
+
179
+ `scan`'s "completed with warnings" (1) and `check`'s "inconclusive" (3) are the same condition under different numbers; `scan`'s was left alone so existing scripts keep working. Example workflows (pull requests, a weekly schedule, manual dispatch, writing the baseline on the runner) are in [docs/github-action.md](docs/github-action.md).
180
+
181
+ ## Removing SrcLawyer
182
+
183
+ SrcLawyer writes at most three files into your project, and only when you ask it to:
184
+
185
+ - `.srclawyer.config.yml`, written by `srclawyer init`.
186
+ - `PRIVACY_POLICY.md`, written by `srclawyer scan` (or the path you give `--out`) unless that file has been edited, in which case [it is left alone](#editing-the-generated-policy).
187
+ - `.srclawyer.inventory.json`, written only when you pass `--write-inventory` to `scan` (or the path you give it).
188
+
189
+ Delete whichever of them you no longer want. No other SrcLawyer command writes files: `srclawyer check` is read-only, and installing the package writes nothing to your project.
190
+
191
+ SrcLawyer never creates a workflow file. To stop the [GitHub Action](#drift-check-github-action), delete or edit the workflow you added yourself (for example `.github/workflows/srclawyer.yml`).
192
+
193
+ To remove the command-line tool:
194
+
195
+ ```bash
196
+ npm uninstall -g srclawyer
197
+ ```
198
+
120
199
  ## Network calls
121
200
 
122
201
  Detection itself is fully local and offline — no code leaves your machine for pattern matching. Once detection completes, every `scan` makes these calls to a SrcLawyer-run service:
package/dist/cli.js CHANGED
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { Command } from "commander";
2
+ import { Command, CommanderError } from "commander";
6
3
  import { isValidEnvVarName, runInit } from "./commands/init.js";
7
4
  import { runScan } from "./commands/scan.js";
5
+ import { runCheck } from "./commands/check.js";
6
+ import { DEFAULT_INVENTORY_PATH } from "./inventory/inventory.js";
7
+ import { EXIT_CRASH } from "./exitCodes.js";
8
+ import { readPackageVersion } from "./version.js";
8
9
  // Read from package.json rather than hardcoding, so --version can't silently drift out of sync with
9
10
  // the published version the way a literal string did through every prior release.
10
- const HERE = dirname(fileURLToPath(import.meta.url));
11
- const { version } = JSON.parse(readFileSync(join(HERE, "../package.json"), "utf8"));
11
+ const version = readPackageVersion();
12
12
  const program = new Command();
13
+ // Commander normally calls process.exit(1) itself on a usage error; exit codes are this CLI's contract
14
+ // (see exitCodes.ts), so it throws instead and the handler at the bottom decides the code.
15
+ program.exitOverride();
13
16
  program
14
17
  .name("srclawyer")
15
18
  .description("Static analysis engine that derives privacy policy documentation directly from source code")
@@ -78,7 +81,43 @@ program
78
81
  .option("--json", "output findings as JSON", false)
79
82
  .option("--out <path>", "path to write the generated privacy policy", "PRIVACY_POLICY.md")
80
83
  .option("--no-llm", "skip AI-assisted resolution of ambiguous findings for this run")
84
+ .option("--write-inventory [path]", `also write the drift baseline (data categories and processors only) to <path> (default ${DEFAULT_INVENTORY_PATH}); uses git-aware file discovery in a git repository`)
85
+ .option("--allow-incomplete-inventory", "with --write-inventory: write the baseline even if the scan completed with warnings")
86
+ .option("--force", "overwrite the policy file even if it was edited since SrcLawyer generated it, or was not generated by SrcLawyer", false)
81
87
  .action(async (opts) => {
82
- await runScan(process.cwd(), { json: opts.json, out: opts.out, llm: opts.llm });
88
+ await runScan(process.cwd(), {
89
+ json: opts.json,
90
+ out: opts.out,
91
+ llm: opts.llm,
92
+ writeInventory: opts.writeInventory === true ? DEFAULT_INVENTORY_PATH : opts.writeInventory,
93
+ allowIncompleteInventory: opts.allowIncompleteInventory,
94
+ force: opts.force,
95
+ });
96
+ });
97
+ program
98
+ .command("check")
99
+ .description("Check for drift: compare the code's data categories and processors with the committed baseline (exit 0 no drift, 1 drift, 2 error, 3 inconclusive)")
100
+ .option("--inventory <path>", "the baseline file", DEFAULT_INVENTORY_PATH)
101
+ .option("--format <format>", "text or json", "text")
102
+ .action(async (opts) => {
103
+ const format = opts.format;
104
+ if (format !== "text" && format !== "json")
105
+ return program.error(`error: --format must be "text" or "json" (got "${format}")`);
106
+ process.exitCode = await runCheck(process.cwd(), { inventoryPath: opts.inventory, format });
107
+ });
108
+ // Exit 1 is reserved for "completed with warnings" (scan) and "drift" (check), so anything that ends
109
+ // the run abnormally -- an uncaught error, an unhandled rejection, a usage error -- exits 2.
110
+ function crash(error) {
111
+ console.error(error instanceof Error ? (error.stack ?? error.message) : String(error));
112
+ process.exit(EXIT_CRASH);
113
+ }
114
+ process.on("uncaughtException", crash);
115
+ process.on("unhandledRejection", crash);
116
+ program.parseAsync(process.argv).catch((error) => {
117
+ if (error instanceof CommanderError) {
118
+ // Help and --version also arrive here, with exitCode 0; commander has already printed its message.
119
+ process.exitCode = error.exitCode === 0 ? 0 : EXIT_CRASH;
120
+ return;
121
+ }
122
+ crash(error);
83
123
  });
84
- program.parseAsync(process.argv);
@@ -0,0 +1,28 @@
1
+ import { type DiscoveryMode, type InventoryDiff } from "../inventory/inventory.js";
2
+ export type CheckFormat = "text" | "json";
3
+ export interface CheckOptions {
4
+ inventoryPath: string;
5
+ format: CheckFormat;
6
+ }
7
+ export interface CheckReport {
8
+ result: "no-drift" | "drift" | "inconclusive" | "error";
9
+ exitCode: number;
10
+ srclawyerVersion: string;
11
+ baseline: {
12
+ path: string;
13
+ srclawyerVersion: string;
14
+ discovery: DiscoveryMode;
15
+ } | null;
16
+ currentDiscovery: DiscoveryMode | null;
17
+ /** Present only for a conclusive result -- an inconclusive run never reports a diff. */
18
+ diff: InventoryDiff | null;
19
+ inconclusiveReasons: string[];
20
+ error: string | null;
21
+ }
22
+ /**
23
+ * Re-derives the inventory and compares it with the committed baseline. Never writes a policy, never
24
+ * calls an LLM provider, and never reports an incomplete scan as drift: anything that makes the scan
25
+ * untrustworthy (the same warnings that make `scan` exit 1, or a discovery-mode mismatch) is
26
+ * inconclusive and carries no diff.
27
+ */
28
+ export declare function runCheck(root: string, options: CheckOptions): Promise<number>;
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { scan } from "../engine/scanEngine.js";
4
+ import { selectDiscovery } from "../engine/gitDiscovery.js";
5
+ import { resolveScanProtectedLogic } from "./protectedLogicStep.js";
6
+ import { computeInventory, diffInventories, hasDrift, inventoryBlockers, parseInventory, } from "../inventory/inventory.js";
7
+ import { EXIT_CRASH, EXIT_DRIFT, EXIT_INCONCLUSIVE, EXIT_OK } from "../exitCodes.js";
8
+ import { readPackageVersion } from "../version.js";
9
+ const FIX_INSTRUCTION = 'If this change is intended, update your privacy policy, then refresh the baseline with "srclawyer scan --write-inventory" and commit it.';
10
+ function listLine(label, values) {
11
+ return values.length > 0 ? [` ${label} ${values.join(", ")}`] : [];
12
+ }
13
+ function renderText(report, inventoryPath) {
14
+ const lines = [];
15
+ switch (report.result) {
16
+ case "no-drift":
17
+ lines.push("Drift check: no drift.");
18
+ lines.push(` Baseline ${inventoryPath} (srclawyer ${report.baseline?.srclawyerVersion}, ${report.baseline?.discovery} discovery) matches the code.`);
19
+ break;
20
+ case "drift": {
21
+ lines.push("Drift check: DRIFT DETECTED.");
22
+ const d = report.diff;
23
+ lines.push(...listLine("Added categories: ", d.added.categories), ...listLine("Removed categories:", d.removed.categories));
24
+ lines.push(...listLine("Added processors: ", d.added.processors), ...listLine("Removed processors:", d.removed.processors));
25
+ if (report.baseline && report.baseline.srclawyerVersion !== report.srclawyerVersion) {
26
+ lines.push(` (The baseline was written by srclawyer ${report.baseline.srclawyerVersion}; this is ${report.srclawyerVersion}. Detection rules can change between versions.)`);
27
+ }
28
+ lines.push("", FIX_INSTRUCTION);
29
+ break;
30
+ }
31
+ case "inconclusive":
32
+ lines.push("Drift check: INCONCLUSIVE — no drift verdict, because the scan was not complete or comparable:");
33
+ for (const reason of report.inconclusiveReasons)
34
+ lines.push(` - ${reason}`);
35
+ break;
36
+ case "error":
37
+ lines.push(`error: ${report.error}`);
38
+ break;
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+ function finish(report, options) {
43
+ if (options.format === "json")
44
+ console.log(JSON.stringify(report, null, 2));
45
+ else
46
+ (report.result === "error" ? console.error : console.log)(renderText(report, options.inventoryPath));
47
+ return report.exitCode;
48
+ }
49
+ const baseReport = () => ({
50
+ result: "error",
51
+ exitCode: EXIT_CRASH,
52
+ srclawyerVersion: readPackageVersion(),
53
+ baseline: null,
54
+ currentDiscovery: null,
55
+ diff: null,
56
+ inconclusiveReasons: [],
57
+ error: null,
58
+ });
59
+ /**
60
+ * Re-derives the inventory and compares it with the committed baseline. Never writes a policy, never
61
+ * calls an LLM provider, and never reports an incomplete scan as drift: anything that makes the scan
62
+ * untrustworthy (the same warnings that make `scan` exit 1, or a discovery-mode mismatch) is
63
+ * inconclusive and carries no diff.
64
+ */
65
+ export async function runCheck(root, options) {
66
+ const path = resolve(root, options.inventoryPath);
67
+ if (!existsSync(path)) {
68
+ return finish({ ...baseReport(), error: `no baseline found at ${options.inventoryPath}. Create one with "srclawyer scan --write-inventory" and commit it.` }, options);
69
+ }
70
+ const parsed = parseInventory(readFileSync(path, "utf8"));
71
+ if (!parsed.ok) {
72
+ return finish({ ...baseReport(), error: `the baseline at ${options.inventoryPath} is invalid: ${parsed.reason}. Re-create it with "srclawyer scan --write-inventory".` }, options);
73
+ }
74
+ const baseline = parsed.inventory;
75
+ const discovery = selectDiscovery(root);
76
+ const result = await scan(root, { onlyFiles: discovery.files });
77
+ const protectedLogic = await resolveScanProtectedLogic(result);
78
+ const reasons = inventoryBlockers(result, protectedLogic.warning);
79
+ if (baseline.discovery !== discovery.mode) {
80
+ reasons.push(`Discovery mode mismatch: the baseline was written with ${baseline.discovery} discovery, but this run used ${discovery.mode} discovery${discovery.mode === "glob" ? ` (${discovery.reason})` : ""}. The two see different files, so they can't be compared.`);
81
+ }
82
+ const report = {
83
+ ...baseReport(),
84
+ baseline: { path: options.inventoryPath, srclawyerVersion: baseline.srclawyerVersion, discovery: baseline.discovery },
85
+ currentDiscovery: discovery.mode,
86
+ };
87
+ if (reasons.length > 0) {
88
+ return finish({ ...report, result: "inconclusive", exitCode: EXIT_INCONCLUSIVE, inconclusiveReasons: reasons }, options);
89
+ }
90
+ const current = computeInventory(protectedLogic.findings, { srclawyerVersion: readPackageVersion(), discovery: discovery.mode });
91
+ const diff = diffInventories(baseline, current);
92
+ return finish(hasDrift(diff) ? { ...report, result: "drift", exitCode: EXIT_DRIFT, diff } : { ...report, result: "no-drift", exitCode: EXIT_OK, diff }, options);
93
+ }
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { CONFIG_FILENAME, DEFAULT_CONFIG, configPath, hasUnmigratedLegacyConfig, legacyConfigMessage, writeConfig, } from "../config/config.js";
5
5
  import { NETWORK_CALLS_NOTICE, byokNetworkNotice } from "../policy/legalNotices.js";
6
+ import { DEFAULT_POLICY_PATH, SUGGESTED_DRAFT_PATH, checkPolicyWrite } from "../policy/policyGuard.js";
6
7
  import { runScan } from "./scan.js";
7
8
  export const DEFAULT_API_KEY_ENV_VAR = "ANTHROPIC_API_KEY";
8
9
  const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -177,12 +178,20 @@ export async function runInit(root, flags = {}) {
177
178
  console.log(`\nWrote ${CONFIG_FILENAME}. Run "srclawyer scan" to analyze this codebase.`);
178
179
  let runScanNow = false;
179
180
  if (isInteractive) {
180
- const raw = await ask(lines, "Run a scan now and generate your policy?", "y");
181
- runScanNow = /^y(es)?$/i.test(raw.trim());
181
+ // init never overwrites an existing policy. If one is there and scan would refuse to replace it,
182
+ // don't offer the scan at all; show how to generate a draft next to it instead.
183
+ const existing = checkPolicyWrite(resolve(root, DEFAULT_POLICY_PATH), DEFAULT_POLICY_PATH, false);
184
+ if (existing.allowed) {
185
+ const raw = await ask(lines, "Run a scan now and generate your policy?", "y");
186
+ runScanNow = /^y(es)?$/i.test(raw.trim());
187
+ }
188
+ else {
189
+ console.log(`\n${DEFAULT_POLICY_PATH} already exists and SrcLawyer will not overwrite it, so no scan was run. To generate a policy to compare against it, run:\n\n srclawyer scan --out ${SUGGESTED_DRAFT_PATH}`);
190
+ }
182
191
  }
183
192
  rl.close();
184
193
  if (runScanNow) {
185
194
  console.log("");
186
- await runScan(root, { json: false, out: "PRIVACY_POLICY.md", llm: true });
195
+ await runScan(root, { json: false, out: DEFAULT_POLICY_PATH, llm: true });
187
196
  }
188
197
  }
@@ -0,0 +1,12 @@
1
+ import type { Finding, ScanResult } from "../engine/types.js";
2
+ /**
3
+ * The one place a scan result is handed to the protected-logic service. Not tier-gated, not behind
4
+ * --no-llm: confidence scoring and Zod-schema detection are Layer 1 capabilities, needed on every real
5
+ * scan. A failure degrades honestly (findings keep their safe local fallback and a warning is
6
+ * returned) rather than blocking the scan. Shared by `scan` and `check` so both make exactly the same
7
+ * calls under exactly the same conditions.
8
+ */
9
+ export declare function resolveScanProtectedLogic(result: ScanResult): Promise<{
10
+ findings: Finding[];
11
+ warning: string | null;
12
+ }>;
@@ -0,0 +1,16 @@
1
+ import { resolveProtectedLogic } from "../cloud/resolveProtectedLogic.js";
2
+ import { resolveProtectedLogicEndpoint } from "../cloud/config.js";
3
+ /**
4
+ * The one place a scan result is handed to the protected-logic service. Not tier-gated, not behind
5
+ * --no-llm: confidence scoring and Zod-schema detection are Layer 1 capabilities, needed on every real
6
+ * scan. A failure degrades honestly (findings keep their safe local fallback and a warning is
7
+ * returned) rather than blocking the scan. Shared by `scan` and `check` so both make exactly the same
8
+ * calls under exactly the same conditions.
9
+ */
10
+ export async function resolveScanProtectedLogic(result) {
11
+ const hasScorableFindings = result.findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0 || result.oversizedZodFiles.length > 0;
12
+ if (!hasScorableFindings)
13
+ return { findings: result.findings, warning: null };
14
+ const resolved = await resolveProtectedLogic(result.findings, result.pendingZodCandidates, resolveProtectedLogicEndpoint(), result.oversizedZodFiles);
15
+ return { findings: resolved.findings, warning: resolved.warning };
16
+ }
@@ -2,5 +2,12 @@ export interface ScanOptions {
2
2
  json: boolean;
3
3
  out: string;
4
4
  llm: boolean;
5
+ /** Write the drift baseline to this path (relative to root). With this set the scan uses the shared
6
+ * git-aware discovery mode (see gitDiscovery.ts) for the policy as well, so the two always agree. */
7
+ writeInventory?: string;
8
+ /** Write the baseline even if the scan completed with warnings that make it untrustworthy. */
9
+ allowIncompleteInventory?: boolean;
10
+ /** Overwrite the policy file even if it was edited since SrcLawyer generated it, or was not generated by SrcLawyer. */
11
+ force?: boolean;
5
12
  }
6
13
  export declare function runScan(root: string, options: ScanOptions): Promise<void>;
@@ -4,13 +4,27 @@ import { CONFIG_FILENAME, hasUnmigratedLegacyConfig, legacyConfigMessage, loadCo
4
4
  import { scan } from "../engine/scanEngine.js";
5
5
  import { formatText } from "../report/formatText.js";
6
6
  import { generatePolicyMarkdown } from "../policy/generatePolicy.js";
7
+ import { checkPolicyWrite, withPolicyMarker } from "../policy/policyGuard.js";
7
8
  import { resolveProviderForTier } from "../llm/resolveProviderForTier.js";
8
9
  import { resolveAmbiguousFindings } from "../llm/resolveAmbiguousFindings.js";
9
- import { resolveProtectedLogic } from "../cloud/resolveProtectedLogic.js";
10
- import { resolveProtectedLogicEndpoint } from "../cloud/config.js";
10
+ import { resolveScanProtectedLogic } from "./protectedLogicStep.js";
11
+ import { selectDiscovery } from "../engine/gitDiscovery.js";
12
+ import { computeInventory, inventoryBlockers, serializeInventory } from "../inventory/inventory.js";
13
+ import { EXIT_CRASH } from "../exitCodes.js";
14
+ import { readPackageVersion } from "../version.js";
11
15
  import { MAX_FRAGMENT_BYTES } from "../rules/zodPreFilter.js";
12
16
  import { SCAN_DISCLAIMER_REMINDER, byokNetworkNotice } from "../policy/legalNotices.js";
13
17
  export async function runScan(root, options) {
18
+ // Refuse before any scanning or network call: a plain scan exists to write this file, so if it is
19
+ // protected there is nothing useful to do. With --write-inventory the scan still runs, because the
20
+ // baseline refresh is independent of the policy -- only the policy write is skipped.
21
+ const policyPath = resolve(root, options.out);
22
+ let policyDecision = checkPolicyWrite(policyPath, options.out, options.force ?? false);
23
+ if (!policyDecision.allowed && !options.writeInventory) {
24
+ console.error(policyDecision.message);
25
+ process.exitCode = EXIT_CRASH;
26
+ return;
27
+ }
14
28
  const config = loadConfig(root);
15
29
  let legacyConfigWarning = null;
16
30
  if (!config) {
@@ -22,7 +36,8 @@ export async function runScan(root, options) {
22
36
  console.error(`No ${CONFIG_FILENAME} found. Run "srclawyer init" first.\n`);
23
37
  }
24
38
  }
25
- const result = await scan(root);
39
+ const discovery = options.writeInventory ? selectDiscovery(root) : null;
40
+ const result = await scan(root, { onlyFiles: discovery?.files ?? null });
26
41
  let findings = result.findings;
27
42
  if (result.workspaceWarning)
28
43
  console.error(result.workspaceWarning);
@@ -33,20 +48,11 @@ export async function runScan(root, options) {
33
48
  if (result.maxZodFragmentBytes > 0) {
34
49
  console.error(`Largest Zod-schema fragment this scan: ${result.maxZodFragmentBytes} bytes (hard ceiling: ${MAX_FRAGMENT_BYTES} bytes).`);
35
50
  }
36
- // Not tier-gated, not behind --no-llm: confidence scoring and Zod-schema detection are Layer 1
37
- // capabilities now, needed on every real scan regardless of which LLM tier (if any) is configured.
38
- // A failure here degrades honestly (findings keep their safe local fallback) rather than blocking
39
- // the scan — see resolveProtectedLogic's own per-call error handling.
40
- const hasScorableFindings = findings.some((f) => f.confidenceFactors) || result.pendingZodCandidates.length > 0 || result.oversizedZodFiles.length > 0;
41
- let protectedLogicWarning = null;
42
- if (hasScorableFindings) {
43
- const endpoint = resolveProtectedLogicEndpoint();
44
- const resolved = await resolveProtectedLogic(findings, result.pendingZodCandidates, endpoint, result.oversizedZodFiles);
45
- findings = resolved.findings;
46
- protectedLogicWarning = resolved.warning;
47
- if (protectedLogicWarning)
48
- console.error(protectedLogicWarning);
49
- }
51
+ const protectedLogic = await resolveScanProtectedLogic(result);
52
+ findings = protectedLogic.findings;
53
+ const protectedLogicWarning = protectedLogic.warning;
54
+ if (protectedLogicWarning)
55
+ console.error(protectedLogicWarning);
50
56
  let llmError = null;
51
57
  if (options.llm && config) {
52
58
  const { provider, error } = resolveProviderForTier(config);
@@ -75,13 +81,41 @@ export async function runScan(root, options) {
75
81
  }
76
82
  const ambiguousCount = findings.filter((f) => f.requiresReview).length;
77
83
  const finalResult = { ...result, findings, ambiguousCount };
78
- const policyPath = resolve(root, options.out);
79
- writeFileSync(policyPath, generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning));
84
+ // Checked again now: a long scan leaves time for the file to be edited since the check above.
85
+ policyDecision = checkPolicyWrite(policyPath, options.out, options.force ?? false);
86
+ let policyNote = null;
87
+ if (policyDecision.allowed) {
88
+ writeFileSync(policyPath, withPolicyMarker(generatePolicyMarkdown(finalResult, config, llmError, protectedLogicWarning)));
89
+ }
90
+ else {
91
+ policyNote = `Privacy policy not written. ${policyDecision.message}`;
92
+ console.error(policyNote);
93
+ }
94
+ let inventoryNote = null;
95
+ if (options.writeInventory && discovery) {
96
+ const blockers = inventoryBlockers(result, protectedLogicWarning);
97
+ if (blockers.length > 0 && !options.allowIncompleteInventory) {
98
+ inventoryNote = `Inventory not written: this scan completed with warnings (see above), so its categories may be incomplete and would read as drift later. Fix them and re-run, or pass --allow-incomplete-inventory to write it anyway.`;
99
+ console.error(inventoryNote);
100
+ }
101
+ else {
102
+ const inventory = computeInventory(finalResult.findings, { srclawyerVersion: readPackageVersion(), discovery: discovery.mode });
103
+ writeFileSync(resolve(root, options.writeInventory), serializeInventory(inventory));
104
+ inventoryNote = `Wrote the drift baseline to ${options.writeInventory} (${inventory.categories.length} categories, ${inventory.processors.length} processors; ${discovery.mode} discovery). Commit it, then run "srclawyer check" in CI.`;
105
+ if (discovery.mode === "glob")
106
+ inventoryNote += ` (${discovery.reason}; the baseline records the ordinary filesystem walk.)`;
107
+ console.error(inventoryNote);
108
+ }
109
+ }
80
110
  if (result.unsupportedStackWarning || result.workspaceWarning || result.skippedFilesWarning || llmError || protectedLogicWarning || legacyConfigWarning) {
81
111
  process.exitCode = 1;
82
112
  }
113
+ // A refusal is a failure of what a plain scan was asked to do; with --write-inventory the policy
114
+ // was a side output and skipping it (with the note above) is not.
115
+ if (policyNote && !options.writeInventory)
116
+ process.exitCode = EXIT_CRASH;
83
117
  if (options.json) {
84
- console.log(JSON.stringify({ ...finalResult, policyPath, llmError, protectedLogicWarning, legacyConfigWarning }, null, 2));
118
+ console.log(JSON.stringify({ ...finalResult, policyPath, policyWritten: policyNote === null, policySkippedReason: policyNote, llmError, protectedLogicWarning, legacyConfigWarning, inventoryNote }, null, 2));
85
119
  return;
86
120
  }
87
121
  console.log(formatText(finalResult, { aiReviewEnabled: config?.tier === "byok" }));
@@ -89,6 +123,6 @@ export async function runScan(root, options) {
89
123
  console.log(`\n${protectedLogicWarning}`);
90
124
  if (legacyConfigWarning)
91
125
  console.log(`\n${legacyConfigWarning}`);
92
- console.log(`\nPrivacy policy written to ${join(options.out)}`);
126
+ console.log(policyNote ? "\nPrivacy policy not written (see above)." : `\nPrivacy policy written to ${join(options.out)}`);
93
127
  console.log(`\n${SCAN_DISCLAIMER_REMINDER}`);
94
128
  }
@@ -0,0 +1,20 @@
1
+ import type { DiscoveryMode } from "../inventory/inventory.js";
2
+ /**
3
+ * The shared discovery mode for the drift baseline (`scan --write-inventory`) and `check`. A baseline
4
+ * written on a laptop and checked on a CI runner must see the same files, but the ordinary walk also
5
+ * sees whatever untracked, gitignored output happens to be on disk (generated code, local scratch
6
+ * dirs) that a clean checkout never has -- which would show up as phantom drift. So inside a git
7
+ * repository both sides use `git ls-files --cached --others --exclude-standard` (tracked, plus
8
+ * untracked-but-not-ignored); anywhere else, the ordinary walk. `scan`'s own default discovery is not
9
+ * changed. The mode is recorded in the baseline, and a mismatch is reported as inconclusive.
10
+ */
11
+ export type DiscoverySelection = {
12
+ mode: "git";
13
+ files: Set<string>;
14
+ } | {
15
+ mode: "glob";
16
+ files: null;
17
+ reason: string;
18
+ };
19
+ export declare function selectDiscovery(root: string): DiscoverySelection;
20
+ export declare function discoveryMode(selection: DiscoverySelection): DiscoveryMode;
@@ -0,0 +1,31 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ function firstLine(text) {
4
+ return text.trim().split("\n")[0] ?? "";
5
+ }
6
+ export function selectDiscovery(root) {
7
+ const git = (args) => execFileSync("git", ["-C", root, ...args], { encoding: "buffer", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 });
8
+ try {
9
+ git(["rev-parse", "--is-inside-work-tree"]);
10
+ }
11
+ catch (error) {
12
+ const stderr = error.stderr?.toString() ?? "";
13
+ const reason = error.code === "ENOENT" ? "git is not installed" : stderr.includes("not a git repository") ? "not a git repository" : `git is not usable here (${firstLine(stderr) || "unknown error"})`;
14
+ return { mode: "glob", files: null, reason };
15
+ }
16
+ try {
17
+ const listing = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard"]).toString("utf8");
18
+ const files = new Set();
19
+ for (const relative of listing.split("\0"))
20
+ if (relative)
21
+ files.add(resolve(root, relative));
22
+ return { mode: "git", files };
23
+ }
24
+ catch (error) {
25
+ const stderr = error.stderr?.toString() ?? "";
26
+ return { mode: "glob", files: null, reason: `git ls-files failed (${firstLine(stderr) || "unknown error"})` };
27
+ }
28
+ }
29
+ export function discoveryMode(selection) {
30
+ return selection.mode;
31
+ }
@@ -1,4 +1,5 @@
1
1
  import { glob } from "tinyglobby";
2
+ import { resolve } from "node:path";
2
3
  // expandDirectories: false keeps tinyglobby's matching identical to the fast-glob behavior this
3
4
  // replaced (a pattern matches only what it names, never everything under a matched directory).
4
5
  // tinyglobby/picomatch compiles braces to regex alternation rather than expanding them, so it has no
@@ -8,5 +9,8 @@ import { glob } from "tinyglobby";
8
9
  // traversal order, which neither fast-glob nor tinyglobby guarantees.
9
10
  export async function globFiles(patterns, options) {
10
11
  const { boundary, ...globOptions } = options;
11
- return (await glob(patterns, { expandDirectories: false, ...globOptions, ...(boundary ? { fs: boundary.globFs() } : {}) })).sort();
12
+ const found = (await glob(patterns, { expandDirectories: false, ...globOptions, ...(boundary ? { fs: boundary.globFs() } : {}) })).sort();
13
+ // An optional discovery restriction (git mode) is an intersection with the normal glob, so every
14
+ // ignore pattern and the symlink boundary above still apply unchanged.
15
+ return boundary ? found.filter((p) => boundary.keepsInDiscovery(resolve(options.cwd, p))) : found;
12
16
  }
@@ -10,7 +10,10 @@ export declare class RootBoundary {
10
10
  readonly realRoot: string;
11
11
  private readonly blocked;
12
12
  private readonly resolved;
13
+ private discoveryFilter;
13
14
  constructor(root: string);
15
+ restrictDiscoveryTo(files: Set<string> | null): void;
16
+ keepsInDiscovery(absolutePath: string): boolean;
14
17
  isInsideRealPath(resolved: string): boolean;
15
18
  /** Records a blocked link by its in-repo (lexical) path only -- never its target. */
16
19
  private recordBlocked;
@@ -17,6 +17,9 @@ export class RootBoundary {
17
17
  // through an in-root directory loop is the same link, counted once.
18
18
  blocked = new Map();
19
19
  resolved = new Map();
20
+ // When set, discovery only keeps files in this set (absolute paths) -- see gitDiscovery.ts. It lives
21
+ // here only because this object is already threaded through every glob call.
22
+ discoveryFilter = null;
20
23
  constructor(root) {
21
24
  this.root = root;
22
25
  try {
@@ -26,6 +29,12 @@ export class RootBoundary {
26
29
  this.realRoot = resolve(root);
27
30
  }
28
31
  }
32
+ restrictDiscoveryTo(files) {
33
+ this.discoveryFilter = files;
34
+ }
35
+ keepsInDiscovery(absolutePath) {
36
+ return this.discoveryFilter === null || this.discoveryFilter.has(absolutePath);
37
+ }
29
38
  isInsideRealPath(resolved) {
30
39
  return resolved === this.realRoot || resolved.startsWith(this.realRoot + sep);
31
40
  }
@@ -8,4 +8,8 @@ import type { ScanResult } from "./types.js";
8
8
  * rather than folded into it. Anyone auditing "does this touch the network" can still answer that by
9
9
  * reading this file alone.
10
10
  */
11
- export declare function scan(root: string): Promise<ScanResult>;
11
+ export interface ScanEngineOptions {
12
+ /** Absolute paths discovery is restricted to (git mode, for the drift baseline); unset = ordinary discovery. */
13
+ onlyFiles?: Set<string> | null;
14
+ }
15
+ export declare function scan(root: string, options?: ScanEngineOptions): Promise<ScanResult>;
@@ -103,17 +103,9 @@ async function runPythonCodeRules(filePath, source, root) {
103
103
  }
104
104
  return { findings, failure };
105
105
  }
106
- /**
107
- * Pure, local, offline — no network access anywhere in this function or anything it calls. Zod
108
- * schemas and confidence scoring can no longer be fully resolved here (see pendingZodCandidates and
109
- * every Finding.confidenceFactors) — resolving those against the protected-logic service is a
110
- * separate, explicit step in src/cloud/resolveProtectedLogic.ts, called from commands/scan.ts, the
111
- * same way Layer 2's resolveAmbiguousFindings.ts is a separate step layered on top of this function
112
- * rather than folded into it. Anyone auditing "does this touch the network" can still answer that by
113
- * reading this file alone.
114
- */
115
- export async function scan(root) {
106
+ export async function scan(root, options = {}) {
116
107
  const boundary = new RootBoundary(root);
108
+ boundary.restrictDiscoveryTo(options.onlyFiles ?? null);
117
109
  const [{ projects, workspaceWarning }, unsupportedStackWarning] = await Promise.all([
118
110
  discoverProjects(root, boundary),
119
111
  detectUnsupportedStack(root, boundary),
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Process exit codes -- one table for both commands so they can't drift apart. Documented in the README.
3
+ *
4
+ * code | srclawyer scan | srclawyer check
5
+ * -----+---------------------------------+----------------------------------------------------------
6
+ * 0 | clean | no drift
7
+ * 1 | completed with warnings | drift
8
+ * 2 | crash or bad usage | crash, bad usage, or a missing / invalid baseline
9
+ * 3 | (not used) | inconclusive: a warning that makes `scan` exit 1, or a
10
+ * | | discovery-mode mismatch -- never reported as drift
11
+ *
12
+ * `scan`'s "warnings" (1) and `check`'s "inconclusive" (3) are the same condition under different
13
+ * numbers; `scan`'s was left alone so existing scripts keep working.
14
+ */
15
+ export declare const EXIT_OK = 0;
16
+ export declare const EXIT_SCAN_WARNINGS = 1;
17
+ export declare const EXIT_DRIFT = 1;
18
+ export declare const EXIT_CRASH = 2;
19
+ export declare const EXIT_INCONCLUSIVE = 3;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Process exit codes -- one table for both commands so they can't drift apart. Documented in the README.
3
+ *
4
+ * code | srclawyer scan | srclawyer check
5
+ * -----+---------------------------------+----------------------------------------------------------
6
+ * 0 | clean | no drift
7
+ * 1 | completed with warnings | drift
8
+ * 2 | crash or bad usage | crash, bad usage, or a missing / invalid baseline
9
+ * 3 | (not used) | inconclusive: a warning that makes `scan` exit 1, or a
10
+ * | | discovery-mode mismatch -- never reported as drift
11
+ *
12
+ * `scan`'s "warnings" (1) and `check`'s "inconclusive" (3) are the same condition under different
13
+ * numbers; `scan`'s was left alone so existing scripts keep working.
14
+ */
15
+ export const EXIT_OK = 0;
16
+ export const EXIT_SCAN_WARNINGS = 1;
17
+ export const EXIT_DRIFT = 1;
18
+ export const EXIT_CRASH = 2;
19
+ export const EXIT_INCONCLUSIVE = 3;
@@ -0,0 +1,54 @@
1
+ import type { Finding, ScanResult } from "../engine/types.js";
2
+ /**
3
+ * The drift baseline: WHICH data categories and third-party processors the code touches, and nothing
4
+ * else -- no file names, line numbers, field names or timestamps. Users hand-edit their generated
5
+ * policy, so drift is judged against this, never against policy text.
6
+ *
7
+ * Built from Layer 1 only (finding.dataCategories / finding.processor) over ALL findings: AI-assisted
8
+ * review never changes it (so it's identical on every tier), and a finding moving between "confident"
9
+ * and "needs review" is not drift.
10
+ */
11
+ export declare const INVENTORY_SCHEMA = 1;
12
+ export declare const DEFAULT_INVENTORY_PATH = ".srclawyer.inventory.json";
13
+ /** "git": git ls-files --cached --others --exclude-standard; "glob": the ordinary filesystem walk. */
14
+ export type DiscoveryMode = "git" | "glob";
15
+ export interface Inventory {
16
+ schema: number;
17
+ /** Informational only -- never compared. Lets a drift report say "the baseline was written by an older version". */
18
+ srclawyerVersion: string;
19
+ discovery: DiscoveryMode;
20
+ categories: string[];
21
+ processors: string[];
22
+ }
23
+ export declare function computeInventory(findings: Finding[], meta: {
24
+ srclawyerVersion: string;
25
+ discovery: DiscoveryMode;
26
+ }): Inventory;
27
+ /** Fixed key order, two-space indent, trailing newline: the same inventory is always the same bytes. */
28
+ export declare function serializeInventory(inventory: Inventory): string;
29
+ export type ParsedInventory = {
30
+ ok: true;
31
+ inventory: Inventory;
32
+ } | {
33
+ ok: false;
34
+ reason: string;
35
+ };
36
+ export declare function parseInventory(text: string): ParsedInventory;
37
+ export interface InventoryDiff {
38
+ added: {
39
+ categories: string[];
40
+ processors: string[];
41
+ };
42
+ removed: {
43
+ categories: string[];
44
+ processors: string[];
45
+ };
46
+ }
47
+ export declare function diffInventories(baseline: Inventory, current: Inventory): InventoryDiff;
48
+ export declare function hasDrift(diff: InventoryDiff): boolean;
49
+ /**
50
+ * The warnings that make a drift verdict untrustworthy -- exactly the ones that make `scan` exit 1,
51
+ * minus the two that can't affect Layer 1 categories (an AI-review misconfiguration, and the legacy
52
+ * config filename). A degraded scan is missing findings, which would read as "removed" categories.
53
+ */
54
+ export declare function inventoryBlockers(result: ScanResult, protectedLogicWarning: string | null): string[];
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The drift baseline: WHICH data categories and third-party processors the code touches, and nothing
3
+ * else -- no file names, line numbers, field names or timestamps. Users hand-edit their generated
4
+ * policy, so drift is judged against this, never against policy text.
5
+ *
6
+ * Built from Layer 1 only (finding.dataCategories / finding.processor) over ALL findings: AI-assisted
7
+ * review never changes it (so it's identical on every tier), and a finding moving between "confident"
8
+ * and "needs review" is not drift.
9
+ */
10
+ export const INVENTORY_SCHEMA = 1;
11
+ export const DEFAULT_INVENTORY_PATH = ".srclawyer.inventory.json";
12
+ // Default code-point sort, deliberately not localeCompare: locale-aware ordering differs between
13
+ // machines (ICU data, LANG), which would make the committed file differ byte-for-byte between a laptop and CI.
14
+ function sortedUnique(values) {
15
+ return [...new Set(values)].sort();
16
+ }
17
+ export function computeInventory(findings, meta) {
18
+ return {
19
+ schema: INVENTORY_SCHEMA,
20
+ srclawyerVersion: meta.srclawyerVersion,
21
+ discovery: meta.discovery,
22
+ categories: sortedUnique(findings.flatMap((f) => f.dataCategories)),
23
+ processors: sortedUnique(findings.flatMap((f) => (f.processor ? [f.processor] : []))),
24
+ };
25
+ }
26
+ /** Fixed key order, two-space indent, trailing newline: the same inventory is always the same bytes. */
27
+ export function serializeInventory(inventory) {
28
+ const ordered = {
29
+ schema: inventory.schema,
30
+ srclawyerVersion: inventory.srclawyerVersion,
31
+ discovery: inventory.discovery,
32
+ categories: sortedUnique(inventory.categories),
33
+ processors: sortedUnique(inventory.processors),
34
+ };
35
+ return JSON.stringify(ordered, null, 2) + "\n";
36
+ }
37
+ export function parseInventory(text) {
38
+ let raw;
39
+ try {
40
+ raw = JSON.parse(text.replace(/^/, ""));
41
+ }
42
+ catch {
43
+ return { ok: false, reason: "it is not valid JSON" };
44
+ }
45
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
46
+ return { ok: false, reason: "it is not a JSON object" };
47
+ const o = raw;
48
+ if (o.schema !== INVENTORY_SCHEMA)
49
+ return { ok: false, reason: `unsupported schema ${JSON.stringify(o.schema)} (this version reads schema ${INVENTORY_SCHEMA})` };
50
+ if (o.discovery !== "git" && o.discovery !== "glob")
51
+ return { ok: false, reason: '"discovery" must be "git" or "glob"' };
52
+ const stringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string");
53
+ if (!stringArray(o.categories))
54
+ return { ok: false, reason: '"categories" must be an array of strings' };
55
+ if (!stringArray(o.processors))
56
+ return { ok: false, reason: '"processors" must be an array of strings' };
57
+ return {
58
+ ok: true,
59
+ inventory: {
60
+ schema: INVENTORY_SCHEMA,
61
+ srclawyerVersion: typeof o.srclawyerVersion === "string" ? o.srclawyerVersion : "unknown",
62
+ discovery: o.discovery,
63
+ categories: sortedUnique(o.categories),
64
+ processors: sortedUnique(o.processors),
65
+ },
66
+ };
67
+ }
68
+ function difference(from, minus) {
69
+ const exclude = new Set(minus);
70
+ return from.filter((v) => !exclude.has(v));
71
+ }
72
+ export function diffInventories(baseline, current) {
73
+ return {
74
+ added: {
75
+ categories: difference(current.categories, baseline.categories),
76
+ processors: difference(current.processors, baseline.processors),
77
+ },
78
+ removed: {
79
+ categories: difference(baseline.categories, current.categories),
80
+ processors: difference(baseline.processors, current.processors),
81
+ },
82
+ };
83
+ }
84
+ export function hasDrift(diff) {
85
+ return Object.values(diff.added).some((v) => v.length > 0) || Object.values(diff.removed).some((v) => v.length > 0);
86
+ }
87
+ /**
88
+ * The warnings that make a drift verdict untrustworthy -- exactly the ones that make `scan` exit 1,
89
+ * minus the two that can't affect Layer 1 categories (an AI-review misconfiguration, and the legacy
90
+ * config filename). A degraded scan is missing findings, which would read as "removed" categories.
91
+ */
92
+ export function inventoryBlockers(result, protectedLogicWarning) {
93
+ return [result.unsupportedStackWarning, result.workspaceWarning, result.skippedFilesWarning, protectedLogicWarning].filter((w) => !!w);
94
+ }
@@ -0,0 +1,20 @@
1
+ /** Where `scan` writes the policy unless told otherwise. */
2
+ export declare const DEFAULT_POLICY_PATH = "PRIVACY_POLICY.md";
3
+ /** The path suggested for a side-by-side draft when the real policy is protected. */
4
+ export declare const SUGGESTED_DRAFT_PATH = "PRIVACY_POLICY.generated.md";
5
+ /** Appends the marker line to a freshly generated policy. */
6
+ export declare function withPolicyMarker(body: string): string;
7
+ export type PolicyFileState = "missing" | "unchanged" | "edited" | "unmarked";
8
+ /** What an existing file at `path` is, relative to what SrcLawyer last generated. */
9
+ export declare function classifyPolicyFile(path: string): PolicyFileState;
10
+ export type PolicyWriteDecision = {
11
+ allowed: true;
12
+ } | {
13
+ allowed: false;
14
+ kind: "edited" | "unmarked";
15
+ message: string;
16
+ };
17
+ export declare function editedMessage(path: string): string;
18
+ export declare function unmarkedMessage(path: string): string;
19
+ /** Whether `scan` may write its policy to `path`. `displayPath` is the path as the user gave it. */
20
+ export declare function checkPolicyWrite(path: string, displayPath: string, force: boolean): PolicyWriteDecision;
@@ -0,0 +1,70 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ // Overwrite protection for the generated privacy policy. Every policy SrcLawyer writes ends with a
4
+ // marker line holding a SHA-256 of the rest of the file. Before writing over an existing file we
5
+ // recompute that hash: a match means nobody has touched it since we generated it (safe to
6
+ // regenerate); anything else means it is someone's work and is left alone unless --force is passed.
7
+ // This detects changes, it is not a security feature -- anyone can recompute the hash.
8
+ const MARKER_PREFIX = "<!-- srclawyer:policy-sha256=";
9
+ const MARKER_LINE = /^<!-- srclawyer:policy-sha256=([0-9a-f]{64}) -->$/;
10
+ /** Where `scan` writes the policy unless told otherwise. */
11
+ export const DEFAULT_POLICY_PATH = "PRIVACY_POLICY.md";
12
+ /** The path suggested for a side-by-side draft when the real policy is protected. */
13
+ export const SUGGESTED_DRAFT_PATH = "PRIVACY_POLICY.generated.md";
14
+ // CRLF/CR become LF and trailing whitespace is dropped, so a Windows checkout (autocrlf) or an editor
15
+ // that adds or strips the final newline is not mistaken for a hand edit.
16
+ function normalize(text) {
17
+ return text.replace(/\r\n?/g, "\n").trimEnd();
18
+ }
19
+ function sha256(text) {
20
+ return createHash("sha256").update(text, "utf8").digest("hex");
21
+ }
22
+ /** Appends the marker line to a freshly generated policy. */
23
+ export function withPolicyMarker(body) {
24
+ const terminated = body.endsWith("\n") ? body : `${body}\n`;
25
+ return `${terminated}${MARKER_PREFIX}${sha256(normalize(body))} -->\n`;
26
+ }
27
+ /** What an existing file at `path` is, relative to what SrcLawyer last generated. */
28
+ export function classifyPolicyFile(path) {
29
+ if (!existsSync(path))
30
+ return "missing";
31
+ const lines = readFileSync(path, "utf8").replace(/\r\n?/g, "\n").split("\n");
32
+ let markerIndex = -1;
33
+ let recorded = "";
34
+ for (let i = lines.length - 1; i >= 0; i--) {
35
+ const match = MARKER_LINE.exec(lines[i].trim());
36
+ if (match) {
37
+ markerIndex = i;
38
+ recorded = match[1];
39
+ break;
40
+ }
41
+ }
42
+ if (markerIndex === -1)
43
+ return "unmarked";
44
+ // Everything except the marker line counts, including text added after it.
45
+ const rest = lines.filter((_, i) => i !== markerIndex).join("\n");
46
+ return sha256(normalize(rest)) === recorded ? "unchanged" : "edited";
47
+ }
48
+ function alternativeOut(path) {
49
+ return path === SUGGESTED_DRAFT_PATH ? "a different path" : SUGGESTED_DRAFT_PATH;
50
+ }
51
+ export function editedMessage(path) {
52
+ return `Not overwriting ${path}: it has been edited since SrcLawyer generated it. Write the new policy to a separate file with --out ${alternativeOut(path)} and compare, or pass --force to replace it.`;
53
+ }
54
+ export function unmarkedMessage(path) {
55
+ return `Not overwriting ${path}: it was not generated by this version of SrcLawyer (it may be hand-written, or generated before overwrite protection was added in 0.8.0). Write the new policy to a separate file with --out ${alternativeOut(path)} and compare, or pass --force to replace it.`;
56
+ }
57
+ /** Whether `scan` may write its policy to `path`. `displayPath` is the path as the user gave it. */
58
+ export function checkPolicyWrite(path, displayPath, force) {
59
+ if (force)
60
+ return { allowed: true };
61
+ // A directory is not a policy file; let the write fail the way it always has.
62
+ if (existsSync(path) && statSync(path).isDirectory())
63
+ return { allowed: true };
64
+ const state = classifyPolicyFile(path);
65
+ if (state === "edited")
66
+ return { allowed: false, kind: "edited", message: editedMessage(displayPath) };
67
+ if (state === "unmarked")
68
+ return { allowed: false, kind: "unmarked", message: unmarkedMessage(displayPath) };
69
+ return { allowed: true };
70
+ }
@@ -0,0 +1 @@
1
+ export declare function readPackageVersion(): string;
@@ -0,0 +1,9 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ // Read from package.json rather than hardcoded, so no reported version can drift from the published
5
+ // one. Works from both src/ (tsx, tests) and dist/: this file sits one level below package.json either way.
6
+ export function readPackageVersion() {
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ return JSON.parse(readFileSync(join(here, "../package.json"), "utf8")).version;
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "srclawyer",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
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",