argusqa-os 9.8.0 → 9.9.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.
@@ -15,6 +15,7 @@ import { applyOverrides } from
15
15
  import { loadBaseline, saveBaseline, applyBaseline, appendTrend, getCurrentBranch } from '../utils/baseline-manager.js';
16
16
  import { loadRunHistory, recordRunHistory, applyNoiseFilter } from '../utils/noise-filter.js';
17
17
  import { getRecentChanges, linkRootCauses } from '../utils/root-cause-linker.js';
18
+ import { classifySensitivity } from '../utils/sensitivity-classifier.js';
18
19
 
19
20
  const logger = childLogger('report-processor');
20
21
 
@@ -129,9 +130,29 @@ export async function processReport(report, { outputDir, severityOverrides }) {
129
130
  }
130
131
  }
131
132
 
132
- // 4. Write JSON report
133
+ // reportPath is computed here (ahead of the write) so step 3c can point each
134
+ // finding's localRef at the on-disk report file it will be written to.
133
135
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
134
136
  const reportPath = path.join(outputDir, `error-report-${timestamp}.json`);
137
+
138
+ // 3c. Aegis sensitivity classification — tag findings for the egress guards
139
+ // (REDACTION_BOUNDARY_MAX_PLAN.md §6 Step 3). Tagging happens on shallow
140
+ // CLONES, so the LOCAL report below keeps 100% fidelity — Aegis only ever
141
+ // removes detail at an external sink, never on disk.
142
+ // SECURITY-CRITICAL: this is the ONE post-processor that fails CLOSED. On
143
+ // any error the egress guards must redact everything, so we set the
144
+ // _aegisFailClosed flag rather than swallowing-and-continuing.
145
+ if (process.env.ARGUS_REDACT_SENSITIVE !== '0') {
146
+ try {
147
+ const { sensitiveCount } = classifySensitivity(report, { reportRef: path.basename(reportPath) });
148
+ if (sensitiveCount > 0) logger.info(`[ARGUS] Aegis: ${sensitiveCount} finding(s) tagged sensitive`);
149
+ } catch (err) {
150
+ logger.error(`[ARGUS] Aegis classify failed — egress guards will fail closed: ${err.message}`);
151
+ report._aegisFailClosed = true; // sinks read this and redact everything
152
+ }
153
+ }
154
+
155
+ // 4. Write JSON report
135
156
  try {
136
157
  fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); // lgtm[js/network-data-to-file] — intentional: Argus persists crawl findings to a local JSON report file by design
137
158
  } catch (err) {
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Aegis — local re-hydration vault (Step 8 of REDACTION_BOUNDARY_MAX_PLAN.md).
3
+ *
4
+ * OPTIONAL, default OFF (ARGUS_REDACT_VAULT=1 to enable). Implements the enterprise
5
+ * "smart redaction" reversible-tokenization pattern (plan §5.3): a sensitive span is
6
+ * replaced on egress (scrubText's `token` mode) by a deterministic, information-free
7
+ * token (`AEGIS_<hmac16>`), and the token→original mapping is written to a LOCAL,
8
+ * gitignored, 0600 vault file. The token that crosses the trust boundary reveals
9
+ * NOTHING; only an operator on THIS machine — who holds the per-machine key and the
10
+ * vault files — can re-hydrate via `npm run report:rehydrate`.
11
+ *
12
+ * SECURITY POSTURE — fail CLOSED (consistent with the rest of Aegis):
13
+ * - The token is an HMAC tag → it cannot contain the secret. A leak of the egress
14
+ * artifact discloses no plaintext.
15
+ * - If the vault is NOT enabled / wired, scrubText's `token` mode falls back to
16
+ * MASK (never the raw value) — see secret-patterns.js maskValue.
17
+ * - The vault key + mapping files are written 0600 and live under
18
+ * reports/.aegis-vault/ (gitignored). The audit trail is secret-free (token only).
19
+ *
20
+ * Crypto note (plan §5.3 / §13.6): the default token is HMAC-SHA256 — NOT
21
+ * format-preserving, so it needs no cipher. If a shape-preserving token is ever
22
+ * required, use NIST FF1 (SP 800-38G); FF3/FF3-1 were WITHDRAWN by NIST in Feb 2025
23
+ * and must not be used.
24
+ *
25
+ * This module is the ONLY place Aegis does filesystem I/O. secret-patterns.js stays
26
+ * pure — it receives `tokenFor` through the setVaultTokenizer injection seam.
27
+ */
28
+
29
+ import fs from 'fs';
30
+ import path from 'path';
31
+ import crypto from 'crypto';
32
+ import { childLogger } from './logger.js';
33
+ import { getCurrentBranch } from './baseline-manager.js';
34
+ import { setVaultTokenizer } from './secret-patterns.js';
35
+
36
+ const logger = childLogger('aegis-vault');
37
+
38
+ const TOKEN_PREFIX = 'AEGIS_';
39
+ const TOKEN_HEX_LEN = 16; // 64-bit HMAC tag
40
+ // Matches exactly what computeToken emits — used by rehydrate to find tokens.
41
+ const TOKEN_RE = /AEGIS_[0-9a-f]{16}/g;
42
+
43
+ let _key = null; // cached per-machine key (Buffer)
44
+ let _branch = null; // cached branch name (avoid repeated git calls)
45
+ let _wired = false; // setVaultTokenizer registered?
46
+ const _seen = new Set(); // tokens persisted this process (in-run dedup)
47
+
48
+ /** True when the operator opted into the reversible vault. */
49
+ export function vaultEnabled() {
50
+ return process.env.ARGUS_REDACT_VAULT === '1';
51
+ }
52
+
53
+ /** Base vault dir (overridable for tests/operators via ARGUS_REDACT_VAULT_DIR). */
54
+ function vaultDir() {
55
+ return process.env.ARGUS_REDACT_VAULT_DIR || path.join('reports', '.aegis-vault');
56
+ }
57
+
58
+ /** Secret-free provenance/audit dir (sibling of the vault). */
59
+ function auditDir() {
60
+ return process.env.ARGUS_REDACT_AUDIT_DIR || path.join('reports', '.aegis-audit');
61
+ }
62
+
63
+ function keyFile() {
64
+ return path.join(vaultDir(), 'key');
65
+ }
66
+
67
+ function ensureDir(dir) {
68
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* best-effort */ }
69
+ }
70
+
71
+ /**
72
+ * Per-machine HMAC key. Read from reports/.aegis-vault/key if present, else
73
+ * generated (32 random bytes) and persisted 0600. Cached in-process. On a write
74
+ * failure an ephemeral key is used (tokens stay valid within the run).
75
+ * @returns {Buffer}
76
+ */
77
+ export function getKey() {
78
+ if (_key) return _key;
79
+ const file = keyFile();
80
+ try {
81
+ if (fs.existsSync(file)) {
82
+ const hex = fs.readFileSync(file, 'utf8').trim();
83
+ if (/^[0-9a-f]{64}$/i.test(hex)) { _key = Buffer.from(hex, 'hex'); return _key; }
84
+ }
85
+ } catch { /* fall through and regenerate */ }
86
+
87
+ ensureDir(vaultDir());
88
+ const key = crypto.randomBytes(32);
89
+ try {
90
+ // Owner-only (0600) — the key seeds every reversible token. chmod after write is
91
+ // best-effort (no-op on Windows, where access is ACL-based, not POSIX mode bits).
92
+ fs.writeFileSync(file, key.toString('hex'), { encoding: 'utf8', mode: 0o600 });
93
+ try { fs.chmodSync(file, 0o600); } catch { /* mode bits unsupported on this platform */ }
94
+ } catch (err) {
95
+ logger.warn(`[ARGUS] Aegis vault: could not persist key — using an ephemeral key: ${err.message}`);
96
+ }
97
+ _key = key;
98
+ return _key;
99
+ }
100
+
101
+ function currentBranch() {
102
+ if (_branch) return _branch;
103
+ try { _branch = getCurrentBranch() || 'default'; } catch { _branch = 'default'; }
104
+ return _branch;
105
+ }
106
+
107
+ function vaultFile() {
108
+ return path.join(vaultDir(), `${currentBranch()}.jsonl`);
109
+ }
110
+
111
+ /**
112
+ * Deterministic, information-free token for a value (HMAC-SHA256 tag). Same value ⇒
113
+ * same token on this machine, so the external artifact stays diff-stable. Does NOT
114
+ * persist a mapping — use tokenFor for that.
115
+ * @param {*} value
116
+ * @returns {string}
117
+ */
118
+ export function computeToken(value) {
119
+ const v = String(value ?? '');
120
+ const tag = crypto.createHmac('sha256', getKey()).update(v).digest('hex').slice(0, TOKEN_HEX_LEN);
121
+ return TOKEN_PREFIX + tag;
122
+ }
123
+
124
+ /**
125
+ * Append a secret-free provenance line for an egress action. The audit trail
126
+ * records WHAT token was minted, never the plaintext (the token is information-free).
127
+ * Gives the NIST-RMF "traceability" trail without itself storing a secret.
128
+ * @param {object} entry
129
+ */
130
+ export function appendAudit(entry) {
131
+ ensureDir(auditDir());
132
+ const file = path.join(auditDir(), `${currentBranch()}.jsonl`);
133
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
134
+ try {
135
+ fs.appendFileSync(file, line, { encoding: 'utf8', mode: 0o600 });
136
+ try { fs.chmodSync(file, 0o600); } catch { /* unsupported on Windows */ }
137
+ } catch { /* audit is best-effort; never break egress */ }
138
+ }
139
+
140
+ /**
141
+ * Persist a token→value mapping to the vault file (0600). In-run dedup keeps the
142
+ * file from growing per duplicate span within a single process.
143
+ * @param {string} token
144
+ * @param {*} value
145
+ */
146
+ export function storeMapping(token, value) {
147
+ if (_seen.has(token)) return;
148
+ _seen.add(token);
149
+ ensureDir(vaultDir());
150
+ const line = JSON.stringify({ token, value: String(value ?? ''), ts: new Date().toISOString() }) + '\n';
151
+ try {
152
+ fs.appendFileSync(vaultFile(), line, { encoding: 'utf8', mode: 0o600 });
153
+ try { fs.chmodSync(vaultFile(), 0o600); } catch { /* unsupported on Windows */ }
154
+ } catch (err) {
155
+ logger.warn(`[ARGUS] Aegis vault: mapping write failed: ${err.message}`);
156
+ }
157
+ // Secret-free provenance (the token, not the value).
158
+ appendAudit({ action: 'tokenize', token });
159
+ }
160
+
161
+ /**
162
+ * Token for a value: compute the deterministic token AND persist its mapping. This
163
+ * is the function wired into scrubText's `token` mode (via setVaultTokenizer).
164
+ * @param {*} value
165
+ * @returns {string}
166
+ */
167
+ export function tokenFor(value) {
168
+ const token = computeToken(value);
169
+ storeMapping(token, value);
170
+ return token;
171
+ }
172
+
173
+ /**
174
+ * Load every token→value mapping from the vault dir (all *.jsonl, merged) into a
175
+ * Map. Branch-agnostic so rehydrate works regardless of which branch minted a token.
176
+ * @returns {Map<string,string>}
177
+ */
178
+ export function loadVault() {
179
+ const map = new Map();
180
+ const dir = vaultDir();
181
+ let files = [];
182
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { return map; }
183
+ for (const f of files) {
184
+ let raw;
185
+ try { raw = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
186
+ for (const line of raw.split('\n')) {
187
+ const s = line.trim();
188
+ if (!s) continue;
189
+ try {
190
+ const { token, value } = JSON.parse(s);
191
+ if (typeof token === 'string') map.set(token, value);
192
+ } catch { /* skip a malformed line */ }
193
+ }
194
+ }
195
+ return map;
196
+ }
197
+
198
+ /**
199
+ * Re-inflate a redacted object/string by swapping every AEGIS_ token back to its
200
+ * original from the vault. Unknown tokens (no mapping) are left untouched. Returns a
201
+ * BRAND-NEW value — never mutates the input.
202
+ * @param {*} obj
203
+ * @param {Map<string,string>} [vault]
204
+ * @returns {*}
205
+ */
206
+ export function rehydrate(obj, vault) {
207
+ const map = vault || loadVault();
208
+ const walk = (v) => {
209
+ if (typeof v === 'string') {
210
+ return v.replace(TOKEN_RE, (tok) => (map.has(tok) ? map.get(tok) : tok));
211
+ }
212
+ if (Array.isArray(v)) return v.map(walk);
213
+ if (v && typeof v === 'object') {
214
+ const out = {};
215
+ for (const k of Object.keys(v)) out[k] = walk(v[k]);
216
+ return out;
217
+ }
218
+ return v;
219
+ };
220
+ return walk(obj);
221
+ }
222
+
223
+ /**
224
+ * Wire (or un-wire) the scrubText `token` mode seam. When the vault is enabled,
225
+ * registers tokenFor as the tokenizer; otherwise ensures the seam is OFF so token
226
+ * mode falls back to mask (fail-closed). Idempotent + cheap — the egress path calls
227
+ * this before redacting in token mode.
228
+ * @returns {boolean} whether the vault is now wired
229
+ */
230
+ export function ensureVaultWired() {
231
+ if (vaultEnabled()) {
232
+ if (!_wired) { setVaultTokenizer(tokenFor); _wired = true; }
233
+ return true;
234
+ }
235
+ if (_wired) { setVaultTokenizer(null); _wired = false; }
236
+ return false;
237
+ }
238
+
239
+ /** Test seam — clears cached key/branch/dedup/wiring. NOT for production use. */
240
+ export function _resetVaultForTests() {
241
+ _key = null;
242
+ _branch = null;
243
+ _wired = false;
244
+ _seen.clear();
245
+ setVaultTokenizer(null);
246
+ }
247
+
248
+ // Wire on import when the env var is already set (the production case, where the
249
+ // process is launched with ARGUS_REDACT_VAULT=1). The egress path additionally calls
250
+ // ensureVaultWired() lazily, so an env var set after import is still honoured.
251
+ ensureVaultWired();
@@ -29,6 +29,7 @@
29
29
  import { childLogger } from './logger.js';
30
30
  import { parsePrUrl } from './pr-diff-analyzer.js';
31
31
  import { githubFetch } from './github-api.js';
32
+ import { redactForEgress, redactReport, buildRedactionRider } from './sensitivity-classifier.js';
32
33
 
33
34
  const logger = childLogger('github-reporter');
34
35
 
@@ -130,6 +131,12 @@ export function formatPrComment(report, diff) {
130
131
  `**Base URL**: ${baseUrl} `,
131
132
  `**Run time**: ${runDate} `,
132
133
  '',
134
+ // Aegis (Step 6): when findings were redacted at the egress boundary, say so — the
135
+ // reader sees titles/types, never the raw exploit detail. Additive + gated, so a report
136
+ // that carries no `redaction` rider (every pre-Aegis caller / opt-out) renders unchanged.
137
+ ...(report.redaction && report.redaction.redacted > 0
138
+ ? [`> 🔒 ${report.redaction.redacted} finding(s) redacted at the boundary — full detail in the local report`, '']
139
+ : []),
133
140
  ...(report.prValidation ? prValidationBanner(report.prValidation) : []),
134
141
  '| | 🔴 Critical | 🟡 Warning | 🔵 Info | Total |',
135
142
  '|---|---|---|---|---|',
@@ -524,9 +531,20 @@ export function isGitHubConfigured() {
524
531
  export async function reportToGitHub(report, diff) {
525
532
  const tasks = [];
526
533
 
534
+ // Aegis egress guard (Step 6): GitHub (PR comment, Check Run, commit status) is a
535
+ // third-party sink read by anyone with repo access (A2). Project the WHOLE report once
536
+ // through redactReport — every finding array reduced to the deny-by-default allowlist, a
537
+ // `redaction` rider attached (drives the comment's 🔒 notice), metadata + severity counts
538
+ // preserved (so buildStatusPayload's merge gate is unchanged). The findings are already
539
+ // step-3c-tagged in the runCrawl path, so redactReport honours each finding's `sensitive`
540
+ // flag + the report-level `_aegisFailClosed`. ARGUS_REDACT_SENSITIVE=0 ⇒ raw passthrough.
541
+ const guarded = process.env.ARGUS_REDACT_SENSITIVE === '0'
542
+ ? report
543
+ : redactReport(report, { localReportPath: process.env.ARGUS_REPORT_URL || null });
544
+
527
545
  if (process.env.GITHUB_PR_NUMBER) {
528
546
  tasks.push(
529
- postPrComment(report, diff).catch(err =>
547
+ postPrComment(guarded, diff).catch(err =>
530
548
  logger.warn(`[ARGUS] C2: PR comment failed — ${err.message}`)
531
549
  )
532
550
  );
@@ -535,7 +553,7 @@ export async function reportToGitHub(report, diff) {
535
553
  if (process.env.GITHUB_SHA) {
536
554
  // Commit status (fast, minimal)
537
555
  tasks.push(
538
- setCommitStatus(report, diff).catch(err =>
556
+ setCommitStatus(guarded, diff).catch(err =>
539
557
  logger.warn(`[ARGUS] C2: Commit status failed — ${err.message}`)
540
558
  )
541
559
  );
@@ -543,7 +561,7 @@ export async function reportToGitHub(report, diff) {
543
561
  // Check Run (rich output — created and completed in sequence)
544
562
  tasks.push(
545
563
  createCheckRun(undefined, process.env.GITHUB_SHA)
546
- .then(id => completeCheckRun(id, report, diff))
564
+ .then(id => completeCheckRun(id, guarded, diff))
547
565
  .catch(err =>
548
566
  logger.warn(`[ARGUS] C2: Check run failed — ${err.message}`)
549
567
  )
@@ -690,7 +708,18 @@ export async function reportPrValidation(result, { prUrl } = {}) {
690
708
  return { posted: false, checked: false, skipped: true, reason: 'no resolvable repo / PR number — PR reporting skipped' };
691
709
  }
692
710
 
693
- const report = prResultToReport(result);
711
+ // Aegis egress guard (Step 6): the PR-Validator path reaches here with RAW findings (it does
712
+ // not run report-processor step 3c). Project them through redactForEgress BEFORE prResultToReport
713
+ // builds the comment, so the posted PR comment / Check Run carry titles + scrubbed messages, never
714
+ // exploit detail (A1/A2). The block decision was already computed upstream on the RAW findings.
715
+ // Attach a `redaction` rider so formatPrComment renders the 🔒 notice. Opt-out ⇒ raw passthrough.
716
+ const guardedResult = process.env.ARGUS_REDACT_SENSITIVE === '0'
717
+ ? result
718
+ : { ...result, findings: redactForEgress(result?.findings ?? []) };
719
+ const report = prResultToReport(guardedResult);
720
+ if (process.env.ARGUS_REDACT_SENSITIVE !== '0') {
721
+ report.redaction = buildRedactionRider(result?.findings ?? [], { localReportPath: null });
722
+ }
694
723
  // Non-first diff so findings render (not treated as a baseline-establishing first run). The
695
724
  // new/persisting split rides on each finding's `isNew` tag (set in the PR-validate paths via
696
725
  // tagFindingNovelty); `resolvedCount` comes from the head-vs-base diff (Phase B2) so the
@@ -21,9 +21,27 @@ import fs from 'fs';
21
21
  import path from 'path';
22
22
  import { fileURLToPath } from 'url';
23
23
  import { childLogger } from './logger.js';
24
+ import { redactReport } from './sensitivity-classifier.js';
24
25
 
25
26
  const logger = childLogger('html-reporter');
26
27
 
28
+ /**
29
+ * Aegis (Step 7): decide whether an HTML report crosses the trust boundary and must be
30
+ * redacted. The default LOCALLY-opened report keeps 100% fidelity; a hosted/shared report
31
+ * (CI artifact, shared link) is projected through redactForEgress. Precedence:
32
+ * ARGUS_REDACT_SENSITIVE=0 → never (global opt-out) · opts.external → always ·
33
+ * ARGUS_REDACT_HTML=1 → always · ARGUS_REDACT_HTML=0 → never · else default ON under CI.
34
+ * @param {{ external?: boolean }} [opts]
35
+ * @returns {boolean}
36
+ */
37
+ function shouldRedactHtml(opts = {}) {
38
+ if (process.env.ARGUS_REDACT_SENSITIVE === '0') return false;
39
+ if (opts.external === true) return true;
40
+ if (process.env.ARGUS_REDACT_HTML === '1') return true;
41
+ if (process.env.ARGUS_REDACT_HTML === '0') return false;
42
+ return process.env.CI === 'true' || process.env.CI === '1';
43
+ }
44
+
27
45
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
46
  const REPORTS_DIR = path.resolve(__dirname, '../../reports');
29
47
 
@@ -441,15 +459,22 @@ function buildHtml(report) {
441
459
  * Called automatically by crawl-and-report.js when Slack is not configured (D7.7).
442
460
  *
443
461
  * @param {string} reportPath - Absolute or relative path to the JSON report file
462
+ * @param {{ external?: boolean }} [opts] - external:true forces egress redaction (hosted/shared
463
+ * report); default keeps full local fidelity unless ARGUS_REDACT_HTML/CI apply (shouldRedactHtml)
444
464
  * @returns {string} Absolute path to the written report.html
445
465
  */
446
- export function generateHtmlReport(reportPath) {
466
+ export function generateHtmlReport(reportPath, opts = {}) {
447
467
  let report;
448
468
  try {
449
469
  report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
450
470
  } catch (err) {
451
471
  throw new Error(`[ARGUS] Failed to parse report JSON at ${reportPath}: ${err.message}`);
452
472
  }
473
+ // Aegis egress guard (Step 7): a hosted/shared HTML report is a third-party sink (A2). When this
474
+ // render is external, project the parsed report through redactReport BEFORE buildHtml — the
475
+ // on-disk JSON at reportPath is left untouched (full local fidelity). The locally-opened default
476
+ // render stays raw. fail-CLOSED is honoured via report._aegisFailClosed inside redactReport.
477
+ if (shouldRedactHtml(opts)) report = redactReport(report, { localReportPath: reportPath });
453
478
  const html = buildHtml(report);
454
479
  const outPath = path.join(path.dirname(path.resolve(reportPath)), 'report.html');
455
480
 
@@ -31,7 +31,6 @@ const logger = childLogger('import-graph');
31
31
 
32
32
  // Source extensions we parse + resolve, in resolution-preference order.
33
33
  export const SOURCE_EXTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
34
- const SOURCE_EXT_SET = new Set(SOURCE_EXTS);
35
34
  const INDEX_BASENAMES = SOURCE_EXTS.map(e => `index${e}`);
36
35
 
37
36
  // Stylesheet (asset) extensions tracked as LEAF nodes in the graph (PR_VALIDATOR C3): a changed
@@ -243,8 +242,15 @@ export function buildImportGraph(rootDir) {
243
242
 
244
243
  let src;
245
244
  try {
246
- if (fs.statSync(file).size > MAX_FILE_BYTES) continue; // skip giant files
247
- src = fs.readFileSync(file, 'utf8');
245
+ // Open once and stat/read the SAME descriptor — avoids a stat→read TOCTOU on `file`
246
+ // (CodeQL js/file-system-race). The size guard still skips pathologically large files.
247
+ const fd = fs.openSync(file, 'r');
248
+ try {
249
+ if (fs.fstatSync(fd).size > MAX_FILE_BYTES) continue; // skip giant files
250
+ src = fs.readFileSync(fd, 'utf8');
251
+ } finally {
252
+ fs.closeSync(fd);
253
+ }
248
254
  } catch { continue; }
249
255
 
250
256
  for (const spec of parseImports(src)) {
@@ -366,8 +366,6 @@ export function mapFilesToRoutesDeep(changedFiles, routes, { sourceDir } = {}) {
366
366
  if (!routeFileToPaths.has(file)) routeFileToPaths.set(file, new Set());
367
367
  routeFileToPaths.get(file).add(rp);
368
368
  }
369
- const routeFileSet = new Set(routeFileToPaths.keys());
370
-
371
369
  // Resolve every changed app file to the route paths it can affect.
372
370
  const resolvedPaths = new Set();
373
371
  for (const f of appFiles) {