@alint-js/core 0.6.0 → 0.7.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.
Files changed (3) hide show
  1. package/dist/index.d.mts +30 -21
  2. package/dist/index.mjs +1028 -1005
  3. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -4,16 +4,17 @@ import { a as createSourceFile, c as sliceRange, d as stableHash, i as SourceCha
4
4
  import { minimatch } from "minimatch";
5
5
  import { basename, extname, isAbsolute, relative, resolve } from "pathe";
6
6
  import { array, boolean, check, custom, finite, getDotPath, integer, literal, looseObject, maxValue, minValue, number, object, optional, parse, picklist, pipe, record, safeParse, string, union, unknown } from "valibot";
7
- import { randomUUID } from "node:crypto";
8
- import { access, appendFile, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
9
- import { basename as basename$1, dirname, extname as extname$1, isAbsolute as isAbsolute$1, join, relative as relative$1, resolve as resolve$1, sep } from "node:path";
10
7
  import process, { cwd } from "node:process";
11
8
  import { Buffer } from "node:buffer";
9
+ import { randomUUID } from "node:crypto";
12
10
  import { statSync } from "node:fs";
11
+ import { access, appendFile, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
12
+ import { basename as basename$1, dirname, extname as extname$1, isAbsolute as isAbsolute$1, join, relative as relative$1, resolve as resolve$1, sep } from "node:path";
13
13
  import lockfile from "proper-lockfile";
14
14
  import { isNodeErrorCode } from "@alint-js/utils/node";
15
15
  import { errorMessageFrom } from "@moeru/std/error";
16
16
  import { AsyncLocalStorage } from "node:async_hooks";
17
+ import { setImmediate } from "node:timers/promises";
17
18
  import { streamText } from "@xsai/stream-text";
18
19
  //#region src/config/config-array.ts
19
20
  const effectiveBasePathSymbol = Symbol("effectiveBasePath");
@@ -269,848 +270,480 @@ function mergeStopGateConfig(resolved, item) {
269
270
  };
270
271
  }
271
272
  //#endregion
272
- //#region src/core/languages/js/index.ts
273
- const javascriptLanguage = {
274
- extensions: [
275
- ".cjs",
276
- ".js",
277
- ".jsx",
278
- ".mjs"
279
- ],
280
- extract: (file) => extractJsSourceTargets(withLanguage(file, "javascript")),
281
- name: "javascript"
282
- };
273
+ //#region package.json
274
+ var version = "0.7.1";
283
275
  //#endregion
284
- //#region src/core/languages/registry.ts
285
- function createLanguageRegistry$1() {
276
+ //#region src/core/cache/types.ts
277
+ const CACHE_MAGIC = "ALINT_CACHE";
278
+ //#endregion
279
+ //#region src/core/cache/store.ts
280
+ const DEFAULT_CACHE_FILE_NAME = ".alintcache";
281
+ const LOCK_STALE_MS = 2e4;
282
+ const LOCK_UPDATE_MS = 3e3;
283
+ const METADATA_READ_LIMIT = 4096;
284
+ const FiniteNumberSchema = pipe(number(), finite());
285
+ const PositionSchema = object({
286
+ column: FiniteNumberSchema,
287
+ line: FiniteNumberSchema
288
+ });
289
+ const SourceLocationSchema = object({
290
+ end: PositionSchema,
291
+ start: PositionSchema
292
+ });
293
+ const DiagnosticLocationSchema = object({
294
+ end: optional(PositionSchema),
295
+ start: PositionSchema
296
+ });
297
+ const SourceRangeSchema = object({
298
+ end: FiniteNumberSchema,
299
+ start: FiniteNumberSchema
300
+ });
301
+ const DiagnosticSchema = object({
302
+ cached: optional(boolean()),
303
+ evidence: optional(custom(isJsonValue)),
304
+ filePath: string(),
305
+ loc: optional(DiagnosticLocationSchema),
306
+ message: string(),
307
+ model: optional(object({
308
+ providerId: string(),
309
+ requested: optional(string()),
310
+ resolvedId: string()
311
+ })),
312
+ ruleId: string(),
313
+ severity: union([literal("error"), literal("warn")])
314
+ });
315
+ const UsageSchema = object({
316
+ filePath: optional(string()),
317
+ inputTokens: optional(FiniteNumberSchema),
318
+ metadata: optional(custom(isJsonValue)),
319
+ modelId: string(),
320
+ outputTokens: optional(FiniteNumberSchema),
321
+ providerId: string(),
322
+ ruleId: string(),
323
+ totalTokens: optional(FiniteNumberSchema)
324
+ });
325
+ const CacheEntrySchema = object({
326
+ diagnostics: array(DiagnosticSchema),
327
+ fingerprint: object({
328
+ configHash: string(),
329
+ modelHash: string(),
330
+ ruleHash: string(),
331
+ targetHash: string()
332
+ }),
333
+ target: object({
334
+ hash: string(),
335
+ identity: string(),
336
+ kind: string(),
337
+ loc: optional(SourceLocationSchema),
338
+ name: optional(string()),
339
+ range: optional(SourceRangeSchema)
340
+ }),
341
+ usage: array(UsageSchema)
342
+ });
343
+ const CachedOwnerSchema = object({
344
+ contentHash: optional(string()),
345
+ kind: union([literal("file"), literal("project")]),
346
+ path: string(),
347
+ slots: array(string())
348
+ });
349
+ function objectRecord(value) {
350
+ return pipe(unknown(), check((input) => typeof input === "object" && input !== null && !Array.isArray(input)), record(string(), value));
351
+ }
352
+ const CacheFileBodySchema = pipe(object({
353
+ createdAt: string(),
354
+ entries: objectRecord(CacheEntrySchema),
355
+ owners: objectRecord(CachedOwnerSchema),
356
+ updatedAt: string()
357
+ }), check((body) => {
358
+ const entryKeys = Object.keys(body.entries);
359
+ const referencedSlots = Object.values(body.owners).flatMap((owner) => owner.slots);
360
+ const uniqueSlots = new Set(referencedSlots);
361
+ return uniqueSlots.size === referencedSlots.length && uniqueSlots.size === entryKeys.length && entryKeys.every((key) => uniqueSlots.has(key));
362
+ }));
363
+ const CacheMetadataSchema = object({
364
+ alintVersion: string(),
365
+ createdAt: string(),
366
+ magic: literal(CACHE_MAGIC),
367
+ schemaVersion: literal(2),
368
+ type: literal("metadata")
369
+ });
370
+ const PutEventSchema = object({
371
+ at: string(),
372
+ entry: CacheEntrySchema,
373
+ owner: CachedOwnerSchema,
374
+ ownerKey: string(),
375
+ slotKey: string(),
376
+ type: literal("put")
377
+ });
378
+ const ReplaceOwnerEventSchema = object({
379
+ at: string(),
380
+ entries: array(object({
381
+ entry: CacheEntrySchema,
382
+ slotKey: string()
383
+ })),
384
+ mode: union([literal("merge"), literal("replace")]),
385
+ owner: CachedOwnerSchema,
386
+ ownerKey: string(),
387
+ type: literal("replace-owner")
388
+ });
389
+ const RemoveOwnerEventSchema = object({
390
+ at: string(),
391
+ ownerKey: string(),
392
+ type: literal("remove-owner")
393
+ });
394
+ const CacheEventSchema = union([
395
+ PutEventSchema,
396
+ ReplaceOwnerEventSchema,
397
+ RemoveOwnerEventSchema
398
+ ]);
399
+ async function createCacheStore(options) {
400
+ const location = resolveCacheLocation(options.cwd, options.location);
401
+ if (!options.enabled) return createNoopCacheStore(location);
402
+ const alintVersion = options.alintVersion ?? version;
403
+ const initial = await loadCacheLog(location, alintVersion);
404
+ if (options.readOnly) return createReadOnlyCacheStore(location, initial.body, options.cwd);
405
+ const dependencies = {
406
+ append: options.appendFile ?? appendFile,
407
+ lock: options.lock ?? { acquire: lockfile.lock },
408
+ write: options.writeFile ?? writeFile
409
+ };
410
+ const trimmed = await trimOnce(location, alintVersion, initial, dependencies);
411
+ const writer = createEventWriter(location, trimmed.metadata, dependencies);
412
+ const fileExists = options.fileExists ?? defaultFileExists;
286
413
  return {
287
- byExtension: /* @__PURE__ */ new Map(),
288
- languages: /* @__PURE__ */ new Map()
414
+ beginOwner: (owner, metadata) => beginOwner(trimmed.body, owner, options.cwd, writer, metadata),
415
+ flush: () => writer.flush(false),
416
+ location,
417
+ reconcile: async () => {
418
+ const removedOwners = await collectMissingFileOwners(trimmed.body, options.cwd, fileExists);
419
+ for (const ownerKey of removedOwners) writer.enqueue({
420
+ at: (/* @__PURE__ */ new Date()).toISOString(),
421
+ ownerKey,
422
+ type: "remove-owner"
423
+ });
424
+ await writer.flush(true);
425
+ }
289
426
  };
290
427
  }
291
- function registerLanguage(registry, language) {
292
- const existing = registry.languages.get(language.name);
293
- if (existing && existing !== language) throw new Error(`Duplicate language "${language.name}".`);
294
- for (const extension of language.extensions ?? []) {
295
- const existingOwner = registry.byExtension.get(extension);
296
- if (existingOwner && existingOwner !== language.name) throw new Error(`Duplicate language extension "${extension}".`);
428
+ function createTargetIdentityResolver(targets) {
429
+ const baseCounts = /* @__PURE__ */ new Map();
430
+ const duplicateCandidateCounts = /* @__PURE__ */ new Map();
431
+ for (const target of targets) {
432
+ const base = createBaseTargetIdentity(target);
433
+ baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
297
434
  }
298
- registry.languages.set(language.name, language);
299
- for (const extension of language.extensions ?? []) registry.byExtension.set(extension, language.name);
300
- }
301
- //#endregion
302
- //#region src/core/languages/text.ts
303
- const textLanguage = {
304
- extensions: [],
305
- extract: (file) => [{
306
- file,
307
- identity: "file",
308
- kind: "file",
309
- language: "plaintext",
310
- origin: { physicalPath: file.path },
311
- text: file.text
312
- }],
313
- name: "plaintext"
314
- };
315
- //#endregion
316
- //#region src/core/languages/ts/index.ts
317
- const typescriptLanguage = {
318
- extensions: [
319
- ".cts",
320
- ".mts",
321
- ".ts",
322
- ".tsx"
323
- ],
324
- extract: (file) => extractJsSourceTargets(withLanguage(file, "typescript")),
325
- name: "typescript"
326
- };
327
- //#endregion
328
- //#region src/core/languages/resolve.ts
329
- function resolveLanguage(file, registry, options) {
330
- return resolveLanguageForPath(file.path, registry, options);
331
- }
332
- function resolveLanguageForPath(filePath, registry, options) {
333
- const languageName = options.language ?? options.processedLanguage ?? registry.byExtension.get(extname$1(filePath)) ?? "plaintext";
334
- const language = registry.languages.get(languageName);
335
- if (!language) {
336
- const registered = [...registry.languages.keys()].sort().join(", ");
337
- throw new Error(`Unknown language "${languageName}". Languages come from plugins, and these are registered: ${registered}.`);
435
+ for (const target of targets) {
436
+ const base = createBaseTargetIdentity(target);
437
+ if ((baseCounts.get(base) ?? 0) <= 1) continue;
438
+ const candidate = target.range ? `${base}:${target.range.start}:${target.range.end}` : base;
439
+ duplicateCandidateCounts.set(candidate, (duplicateCandidateCounts.get(candidate) ?? 0) + 1);
338
440
  }
339
- return language;
441
+ return (target, targetIndex) => {
442
+ const base = createBaseTargetIdentity(target);
443
+ if ((baseCounts.get(base) ?? 0) <= 1) return base;
444
+ const candidate = target.range ? `${base}:${target.range.start}:${target.range.end}` : base;
445
+ if ((duplicateCandidateCounts.get(candidate) ?? 0) <= 1) return candidate;
446
+ return `${candidate}:${targetIndex}`;
447
+ };
340
448
  }
341
- //#endregion
342
- //#region src/core/languages/index.ts
343
- function createBuiltInLanguageRegistry() {
344
- const registry = createLanguageRegistry$1();
345
- registerLanguage(registry, textLanguage);
346
- registerLanguage(registry, javascriptLanguage);
347
- registerLanguage(registry, typescriptLanguage);
348
- return registry;
449
+ function normalizeCachePath(cwd, filePath) {
450
+ const resolvedPath = isAbsolute$1(filePath) ? resolve$1(filePath) : resolve$1(cwd, filePath);
451
+ return relative$1(cwd, resolvedPath).split(sep).join("/") || ".";
349
452
  }
350
- //#endregion
351
- //#region src/dsl/registry.ts
352
- function buildRuleRegistry(config) {
353
- const rules = /* @__PURE__ */ new Map();
354
- const localIds = /* @__PURE__ */ new Map();
355
- const enabledRules = [];
356
- for (const [alias, plugin] of Object.entries(config.plugins)) for (const [localId, rule] of Object.entries(plugin.rules ?? {})) {
357
- const id = `${alias}/${localId}`;
358
- if (rules.has(id)) throw new Error(`Duplicate rule id "${id}".`);
359
- rules.set(id, rule);
360
- localIds.set(id, localId);
361
- }
362
- for (const [id, entry] of Object.entries(config.rules)) {
363
- const rule = rules.get(id);
364
- if (!rule) throw new Error(`Unknown rule "${id}".`);
365
- const normalizedEntry = normalizeRuleConfigEntry(entry);
366
- const severity = normalizedEntry.severity;
367
- if (severity === "off") continue;
368
- enabledRules.push({
369
- id,
370
- localId: localIds.get(id) ?? id,
371
- options: parseRuleOptions(id, rule.options, normalizedEntry.options),
372
- rule,
373
- severity
374
- });
375
- }
453
+ function normalizeRunnerCacheConfig(cache, cwd) {
454
+ if (cache === false) return {
455
+ enabled: false,
456
+ location: resolveCacheLocation(cwd)
457
+ };
458
+ if (cache === true || cache === void 0) return {
459
+ enabled: true,
460
+ location: resolveCacheLocation(cwd)
461
+ };
376
462
  return {
377
- enabledRules,
378
- rules
463
+ enabled: cache.enabled ?? true,
464
+ location: resolveCacheLocation(cwd, cache.location)
379
465
  };
380
466
  }
381
- function formatRuleOptionIssues(index, issues) {
382
- return issues.map((issue) => {
383
- const path = getDotPath(issue);
384
- return path === null ? `"${index}": ${issue.message}` : `"${index}.${path}": ${issue.message}`;
385
- }).join("; ");
467
+ function resolveCacheLocation(cwd, location) {
468
+ if (!location) return join(cwd, DEFAULT_CACHE_FILE_NAME);
469
+ const resolved = isAbsolute$1(location) ? resolve$1(location) : resolve$1(cwd, location);
470
+ if (location.endsWith("/") || location.endsWith("\\")) return join(resolved, DEFAULT_CACHE_FILE_NAME);
471
+ try {
472
+ if (statSync(resolved).isDirectory()) return join(resolved, DEFAULT_CACHE_FILE_NAME);
473
+ } catch {}
474
+ return resolved;
386
475
  }
387
- function isRuleConfigTuple(entry) {
388
- return Array.isArray(entry);
476
+ async function appendBatch(location, payload, append) {
477
+ const handle = await open(location, "a+");
478
+ try {
479
+ const { size } = await handle.stat();
480
+ let separator = "";
481
+ if (size > 0) {
482
+ const finalByte = Buffer.alloc(1);
483
+ await handle.read(finalByte, 0, 1, size - 1);
484
+ separator = finalByte[0] === 10 ? "" : "\n";
485
+ }
486
+ await append(location, `${separator}${payload}`, "utf8");
487
+ } finally {
488
+ await handle.close();
489
+ }
389
490
  }
390
- function normalizeRuleConfigEntry(entry) {
391
- if (isRuleConfigTuple(entry)) {
392
- const [severity = "warn", ...options] = entry;
393
- return {
394
- options,
395
- severity
491
+ function applyEvent(body, event) {
492
+ if (event.type === "remove-owner") {
493
+ removeOwner(body, event.ownerKey);
494
+ body.updatedAt = event.at;
495
+ return;
496
+ }
497
+ if (event.type === "put") {
498
+ const current = body.owners[event.ownerKey];
499
+ const slots = new Set(current?.slots ?? []);
500
+ slots.add(event.slotKey);
501
+ body.entries[event.slotKey] = event.entry;
502
+ body.owners[event.ownerKey] = {
503
+ ...event.owner,
504
+ slots: [...slots].sort()
396
505
  };
506
+ body.updatedAt = event.at;
507
+ return;
397
508
  }
509
+ const entries = event.mode === "merge" ? ownerEntries(body, body.owners[event.ownerKey]) : /* @__PURE__ */ new Map();
510
+ for (const item of event.entries) entries.set(item.slotKey, item.entry);
511
+ for (const slot of body.owners[event.ownerKey]?.slots ?? []) delete body.entries[slot];
512
+ for (const [slotKey, entry] of entries) body.entries[slotKey] = entry;
513
+ body.owners[event.ownerKey] = {
514
+ ...event.owner,
515
+ slots: [...entries.keys()].sort()
516
+ };
517
+ body.updatedAt = event.at;
518
+ }
519
+ function beginOwner(body, owner, cwd, writer, ownerMetadata = {}) {
520
+ const normalizedOwner = {
521
+ kind: owner.kind,
522
+ path: normalizeCachePath(cwd, owner.path)
523
+ };
524
+ const key = ownerKey(normalizedOwner, cwd);
525
+ const previousOwner = body.owners[key];
526
+ const nextEntries = /* @__PURE__ */ new Map();
527
+ const checkpointEntries = /* @__PURE__ */ new Map();
528
+ const commit = (metadata = {}) => {
529
+ const contentHash = metadata.contentHash ?? ownerMetadata.contentHash;
530
+ const committedEntries = metadata.mode === "merge" ? ownerEntries(body, body.owners[key]) : /* @__PURE__ */ new Map();
531
+ for (const [entryKey, cacheEntry] of nextEntries) committedEntries.set(entryKey, cacheEntry);
532
+ const replacedSlots = /* @__PURE__ */ new Set([...previousOwner?.slots ?? [], ...body.owners[key]?.slots ?? []]);
533
+ for (const replacedSlot of replacedSlots) delete body.entries[replacedSlot];
534
+ for (const [entryKey, cacheEntry] of committedEntries) body.entries[entryKey] = cacheEntry;
535
+ const cachedOwner = {
536
+ contentHash,
537
+ kind: normalizedOwner.kind,
538
+ path: normalizedOwner.path,
539
+ slots: [...committedEntries.keys()].sort()
540
+ };
541
+ body.owners[key] = cachedOwner;
542
+ body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
543
+ writer.enqueue({
544
+ at: body.updatedAt,
545
+ entries: [...committedEntries].map(([slotKey, entry]) => ({
546
+ entry,
547
+ slotKey
548
+ })),
549
+ mode: metadata.mode ?? "replace",
550
+ owner: cachedOwner,
551
+ ownerKey: key,
552
+ type: "replace-owner"
553
+ });
554
+ checkpointEntries.clear();
555
+ };
398
556
  return {
399
- options: [],
400
- severity: entry ?? "warn"
557
+ checkpoint: async () => {
558
+ const cachedOwner = {
559
+ contentHash: ownerMetadata.contentHash,
560
+ kind: normalizedOwner.kind,
561
+ path: normalizedOwner.path,
562
+ slots: []
563
+ };
564
+ for (const [entryKey, cacheEntry] of checkpointEntries) {
565
+ const event = {
566
+ at: (/* @__PURE__ */ new Date()).toISOString(),
567
+ entry: cacheEntry,
568
+ owner: cachedOwner,
569
+ ownerKey: key,
570
+ slotKey: entryKey,
571
+ type: "put"
572
+ };
573
+ applyEvent(body, event);
574
+ writer.enqueue(event);
575
+ }
576
+ checkpointEntries.clear();
577
+ await writer.flush(false);
578
+ },
579
+ commit,
580
+ discard: (cacheSlot) => {
581
+ const entryKey = slotKey(normalizedOwner, cacheSlot, cwd);
582
+ nextEntries.delete(entryKey);
583
+ checkpointEntries.delete(entryKey);
584
+ },
585
+ lookup: (cacheSlot, fingerprint) => {
586
+ const entryKey = slotKey(normalizedOwner, cacheSlot, cwd);
587
+ const cached = body.entries[entryKey];
588
+ if (!cached || !fingerprintsEqual(cached.fingerprint, fingerprint)) return void 0;
589
+ nextEntries.set(entryKey, cached);
590
+ return cached;
591
+ },
592
+ put: (cacheSlot, cacheEntry) => {
593
+ const entryKey = slotKey(normalizedOwner, cacheSlot, cwd);
594
+ nextEntries.set(entryKey, cacheEntry);
595
+ checkpointEntries.set(entryKey, cacheEntry);
596
+ }
401
597
  };
402
598
  }
403
- function parseRuleOption(id, index, schema, value) {
404
- const result = safeParse(schema, value);
405
- if (result.success) return result.output;
406
- if (value === void 0) {
407
- const emptyObjectResult = safeParse(schema, {});
408
- if (emptyObjectResult.success) return emptyObjectResult.output;
599
+ async function collectMissingFileOwners(body, cwd, fileExists) {
600
+ const removed = [];
601
+ for (const [key, owner] of Object.entries(body.owners)) {
602
+ if (owner.kind !== "file" || await fileExists(resolve$1(cwd, owner.path))) continue;
603
+ removeOwner(body, key);
604
+ removed.push(key);
409
605
  }
410
- throw new TypeError(`Invalid options for rule "${id}": ${formatRuleOptionIssues(index, result.issues)}`);
606
+ return removed;
411
607
  }
412
- function parseRuleOptions(id, schema, options) {
413
- if (!schema) {
414
- if (options.length > 0) throw new TypeError(`Rule "${id}" does not accept options.`);
415
- return [];
416
- }
417
- if (options.length > schema.length) throw new TypeError(`Invalid options for rule "${id}": Unexpected option at index ${schema.length}.`);
418
- return schema.map((optionSchema, index) => parseRuleOption(id, index, optionSchema, options[index]));
608
+ function createBaseTargetIdentity(target) {
609
+ if (target.identity && (target.kind !== "file" || target.identity !== "file")) return target.filePath ? `${target.kind}:${target.filePath}:${target.identity}` : `${target.kind}:${target.identity}`;
610
+ if (target.kind === "file") return target.filePath ? `file:${target.filePath}` : "file";
611
+ if (target.name) return `${target.kind}:${target.name}`;
612
+ if (target.range) return `${target.kind}:${target.range.start}:${target.range.end}`;
613
+ return target.kind;
419
614
  }
420
- //#endregion
421
- //#region src/core/languages/diagnostics.ts
422
- /** A rule named a language and nothing registered it. This is a builtin rule. Not a real rule. */
423
- const MISSING_LANGUAGE_RULE = "alint/missing-language";
424
- /** A linted file's extension belongs to no registered language. This is a builtin rule. Not a real rule either. */
425
- const UNREGISTERED_LANGUAGE_RULE = "alint/unregistered-language";
426
- /**
427
- * One diagnostic per missing language, always an error: a rule scoped to a language nothing provides
428
- * matches no file, so leaving it at a warning would let a run pass having silently checked nothing.
429
- */
430
- function missingLanguageDiagnostics(missing) {
431
- return [...missing.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([languageId, entry]) => {
432
- const ruleIds = [...entry.ruleIds].sort();
433
- const subject = ruleIds.length === 1 ? `Rule "${ruleIds[0]}" needs` : `Rules ${ruleIds.map((id) => `"${id}"`).join(", ")} need`;
434
- return {
435
- filePath: entry.filePath,
436
- message: `${subject} the "${languageId}" language, which no plugin registered. Add a plugin that provides "${languageId}", or turn the rule off.`,
437
- ruleId: MISSING_LANGUAGE_RULE,
438
- severity: "error"
439
- };
440
- });
615
+ function createEmptyCacheBody(createdAt = (/* @__PURE__ */ new Date()).toISOString()) {
616
+ return {
617
+ createdAt,
618
+ entries: {},
619
+ owners: {},
620
+ updatedAt: createdAt
621
+ };
441
622
  }
442
- /** Records one rule that named a language the config never registered, grouped by language id. */
443
- function recordMissingLanguage(into, languageId, ruleId, path) {
444
- const existing = into.get(languageId);
445
- if (existing === void 0) {
446
- into.set(languageId, {
447
- filePath: path,
448
- ruleIds: /* @__PURE__ */ new Set([ruleId])
623
+ function createEventWriter(location, metadata, dependencies) {
624
+ const backlog = [];
625
+ let validationError;
626
+ let previousWrite = Promise.resolve();
627
+ const persistBacklog = async () => {
628
+ if (validationError) throw validationError;
629
+ if (backlog.length === 0) return;
630
+ await mkdir(dirname(location), { recursive: true });
631
+ await withCacheLock(location, dependencies.lock, getCacheLockOptions({
632
+ factor: 2,
633
+ maxTimeout: 16e3,
634
+ minTimeout: 2e3,
635
+ retries: 4
636
+ }), async () => {
637
+ await ensureCompatibleMetadata(location, metadata, dependencies.write);
638
+ const batchSize = backlog.length;
639
+ await appendBatch(location, backlog.slice(0, batchSize).map((event) => `${JSON.stringify(event)}\n`).join(""), dependencies.append);
640
+ backlog.splice(0, batchSize);
449
641
  });
450
- return;
451
- }
452
- existing.ruleIds.add(ruleId);
453
- if (path < existing.filePath) existing.filePath = path;
454
- }
455
- /**
456
- * Records one linted file no language claimed, grouped by extension so a missing pack is one
457
- * diagnostic, not one per file. `'off'` records nothing.
458
- */
459
- function recordUnregistered(into, path, severity) {
460
- if (severity === "off") return;
461
- const key = extname(path) || basename(path);
462
- const existing = into.get(key);
463
- into.set(key, {
464
- count: (existing?.count ?? 0) + 1,
465
- filePath: existing !== void 0 && existing.filePath < path ? existing.filePath : path,
466
- severity: existing?.severity === "error" ? "error" : severity
467
- });
468
- }
469
- /** One diagnostic per extension, sorted, for the run to prepend to its results. */
470
- function unregisteredLanguageDiagnostics(unregistered) {
471
- return [...unregistered.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([extension, entry]) => ({
472
- filePath: entry.filePath,
473
- message: `No language is registered for "${extension}": ${entry.count} ${entry.count === 1 ? "file was" : "files were"} handled as plain text. Register one through a plugin's "languages", or pin \`language: 'plaintext'\` to accept plain text.`,
474
- ruleId: UNREGISTERED_LANGUAGE_RULE,
475
- severity: entry.severity
476
- }));
477
- }
478
- function unregisteredLanguageSeverity(linterOptions) {
479
- const value = linterOptions.reportUnregisteredLanguages;
480
- return value === "error" || value === "off" ? value : "warn";
481
- }
482
- //#endregion
483
- //#region src/core/languages/rule-languages.ts
484
- /**
485
- * The language a file falls back to when no registered language claims its extension. Not a real
486
- * language, which is why `'any'` excludes it.
487
- */
488
- const PLAIN_TEXT = "plaintext";
489
- /**
490
- * Whether a rule reads this target.
491
- *
492
- * A rule with language off still reads file targets, because reading raw text, or asking a model about a
493
- * whole file, needs no language. However, it never reads the function and class targets extraction produces.
494
- * `'file'` is the one kind every language yields, plain text included, so it is the dividing line.
495
- *
496
- * `'any'` means any real language, so a file that fell back to plain text because nothing claimed
497
- * its extension is withheld: the rule asked for structure and there is none to give. The run says so
498
- * once per extension through `alint/unregistered-language` rather than letting each rule report an
499
- * empty file.
500
- */
501
- function isTargetLanguageAccepted(languages, targetKind, targetLanguage) {
502
- if (languages.kind === "off") return targetKind === "file";
503
- if (languages.kind === "any") return targetLanguage !== PLAIN_TEXT;
504
- return languages.ids.includes(targetLanguage);
642
+ validationError = void 0;
643
+ };
644
+ return {
645
+ enqueue: (event) => {
646
+ try {
647
+ backlog.push(parse(CacheEventSchema, event));
648
+ } catch (error) {
649
+ validationError ??= error;
650
+ }
651
+ },
652
+ flush: (finalAttempt) => {
653
+ const write = previousWrite.then(persistBacklog);
654
+ previousWrite = write.catch(() => {});
655
+ return finalAttempt ? write : write.catch(() => {});
656
+ }
657
+ };
505
658
  }
506
- function resolveRuleLanguages(languages) {
507
- if (languages === void 0) return { kind: "off" };
508
- if (languages === "any") return { kind: "any" };
509
- if ("ids" in languages) return {
510
- ids: languages.ids,
511
- kind: "list",
512
- skipMissing: languages.skipMissing ?? false
659
+ function createNoopCacheStore(location) {
660
+ const transaction = {
661
+ checkpoint: async () => {},
662
+ commit: () => {},
663
+ discard: () => {},
664
+ lookup: () => void 0,
665
+ put: () => {}
513
666
  };
514
667
  return {
515
- ids: languages,
516
- kind: "list",
517
- skipMissing: false
668
+ beginOwner: () => transaction,
669
+ flush: async () => {},
670
+ location,
671
+ reconcile: async () => {}
518
672
  };
519
673
  }
520
- //#endregion
521
- //#region src/core/preparation.ts
522
- /**
523
- * The `ctx.src.extract` a run hands to rules, for parsing files it was never asked to lint, which
524
- * an index builder sweeping the workspace needs.
525
- *
526
- * It resolves each file's OWN config rather than a caller's, because the language a file resolves to
527
- * is a config decision and two config groups may register different plugins. It extracts on demand
528
- * and holds nothing: source planning releases rich extractor values after compact jobs are admitted,
529
- * and a run-wide parse memo here would put that retention back. A caller keeps only what it derives.
530
- *
531
- * `getSrc` defers reading the runtime because the runtime is built around this closure. It is assigned
532
- * before any rule runs, so by the first call it is present.
533
- */
534
- function createSourceExtractor(cwd, config, getSrc) {
535
- return async (filePath, options = {}) => {
536
- const path = resolve(cwd, filePath);
537
- const resolvedConfig = resolveConfigForFile(path, config, { cwd });
538
- if (resolvedConfig.ignored) return [];
539
- const effectiveConfig = resolvedConfig.config;
540
- const language = resolveLanguageForPath(path, createLanguageRegistry(effectiveConfig), { language: options.language ?? effectiveConfig.language });
541
- const src = getSrc();
542
- const file = await src.readFile(path);
543
- return language.extract(file, {
544
- cwd,
545
- languageOptions: effectiveConfig.languageOptions,
546
- src
547
- });
674
+ function createReadOnlyCacheStore(location, body, cwd) {
675
+ return {
676
+ beginOwner: (owner) => ({
677
+ checkpoint: async () => {},
678
+ commit: () => {},
679
+ discard: () => {},
680
+ lookup: (slot, fingerprint) => {
681
+ const cached = body.entries[slotKey(owner, slot, cwd)];
682
+ return cached && fingerprintsEqual(cached.fingerprint, fingerprint) ? cached : void 0;
683
+ },
684
+ put: () => {}
685
+ }),
686
+ flush: async () => {},
687
+ location,
688
+ reconcile: async () => {}
548
689
  };
549
690
  }
550
- function prepareRun(options = {}) {
551
- const cwd$2 = options.cwd ?? cwd();
552
- const config = options.config ?? [];
553
- const files = [];
554
- const directories = [];
555
- const missingLanguages = /* @__PURE__ */ new Map();
556
- const unregisteredLanguages = /* @__PURE__ */ new Map();
557
- for (const filePath of options.files ?? []) {
558
- const path = resolve(cwd$2, filePath);
559
- const resolvedConfig = resolveConfigForFile(path, config, { cwd: cwd$2 });
560
- if (resolvedConfig.ignored) continue;
561
- const effectiveConfig = resolvedConfig.config;
562
- const languageRegistry = createLanguageRegistry(effectiveConfig);
563
- const language = resolveLanguageForPath(path, languageRegistry, { language: effectiveConfig.language });
564
- const rules = prepareRules(effectiveConfig);
565
- recordMissingLanguages(missingLanguages, rules, languageRegistry, path);
566
- if (effectiveConfig.language === void 0 && language.name === "plaintext" && rules.some(({ enabledRule }) => !isTargetLanguageAccepted(resolveRuleLanguages(enabledRule.rule.languages), "file", "plaintext"))) recordUnregistered(unregisteredLanguages, path, unregisteredLanguageSeverity(effectiveConfig.linterOptions));
567
- files.push({
568
- agent: effectiveConfig.agent,
569
- configHash: stableHash({
570
- language: effectiveConfig.language,
571
- languageOptions: effectiveConfig.languageOptions,
572
- processor: effectiveConfig.processor,
573
- resolvedLanguage: language.name,
574
- settings: effectiveConfig.settings
575
- }),
576
- fileIndex: files.length,
577
- language,
578
- languageOptions: effectiveConfig.languageOptions,
579
- path,
580
- rules,
581
- settings: effectiveConfig.settings
582
- });
583
- }
584
- for (const directoryPath of options.directories ?? []) {
585
- const path = resolve(cwd$2, directoryPath);
586
- const resolvedConfig = resolveConfigForDirectory(path, config, { cwd: cwd$2 });
587
- if (resolvedConfig.ignored) continue;
588
- const effectiveConfig = resolvedConfig.config;
589
- directories.push({
590
- agent: effectiveConfig.agent,
591
- configHash: stableHash({ settings: effectiveConfig.settings }),
592
- directoryIndex: directories.length,
593
- rules: prepareRules(effectiveConfig),
594
- settings: effectiveConfig.settings,
595
- target: {
596
- kind: "directory",
597
- path
598
- }
599
- });
691
+ async function defaultFileExists(path) {
692
+ try {
693
+ await access(path);
694
+ return true;
695
+ } catch (error) {
696
+ if (isMissingFileError(error)) return false;
697
+ throw error;
600
698
  }
699
+ }
700
+ function emptyLoadedLog(alintVersion) {
701
+ const metadata = {
702
+ alintVersion,
703
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
704
+ magic: CACHE_MAGIC,
705
+ schemaVersion: 2,
706
+ type: "metadata"
707
+ };
601
708
  return {
602
- directories,
603
- files,
604
- missingLanguages,
605
- project: options.projectTargets === false ? void 0 : prepareProject(cwd$2, config),
606
- unregisteredLanguages
709
+ body: createEmptyCacheBody(metadata.createdAt),
710
+ metadata
607
711
  };
608
712
  }
609
- function createLanguageRegistry(config) {
610
- const registry = createBuiltInLanguageRegistry();
611
- for (const plugin of Object.values(config.plugins)) for (const language of Object.values(plugin.languages ?? {})) registerLanguage(registry, language);
612
- return registry;
713
+ async function ensureCompatibleMetadata(location, metadata, write) {
714
+ try {
715
+ if (parseMetadataLine(await readMetadataLine(location), metadata.alintVersion)) return;
716
+ } catch (error) {
717
+ if (!isMissingFileError(error)) throw error;
718
+ }
719
+ await write(location, `${JSON.stringify(metadata)}\n`);
613
720
  }
614
- function prepareProject(root, config) {
615
- const resolvedConfig = resolveConfigForProject(root, config, { cwd: root });
616
- if (resolvedConfig.ignored) return void 0;
617
- const effectiveConfig = resolvedConfig.config;
721
+ function fingerprintsEqual(left, right) {
722
+ return left.configHash === right.configHash && left.modelHash === right.modelHash && left.ruleHash === right.ruleHash && left.targetHash === right.targetHash;
723
+ }
724
+ function getCacheLockOptions(retries) {
618
725
  return {
619
- agent: effectiveConfig.agent,
620
- configHash: stableHash({ settings: effectiveConfig.settings }),
621
- root,
622
- rules: prepareRules(effectiveConfig),
623
- settings: effectiveConfig.settings
726
+ realpath: false,
727
+ retries,
728
+ stale: LOCK_STALE_MS,
729
+ update: LOCK_UPDATE_MS
624
730
  };
625
731
  }
626
- function prepareRules(config) {
627
- return buildRuleRegistry(config).enabledRules.map((enabledRule, ruleIndex) => ({
628
- enabledRule,
629
- ruleIndex
630
- }));
631
- }
632
- function recordMissingLanguages(into, rules, registry, path) {
633
- for (const { enabledRule } of rules) {
634
- const languages = resolveRuleLanguages(enabledRule.rule.languages);
635
- if (languages.kind !== "list" || languages.skipMissing) continue;
636
- for (const languageId of languages.ids) if (!registry.languages.has(languageId)) recordMissingLanguage(into, languageId, enabledRule.id, path);
637
- }
638
- }
639
- //#endregion
640
- //#region package.json
641
- var version = "0.6.0";
642
- //#endregion
643
- //#region src/core/cache/types.ts
644
- const CACHE_MAGIC = "ALINT_CACHE";
645
- //#endregion
646
- //#region src/core/cache/store.ts
647
- const DEFAULT_CACHE_FILE_NAME = ".alintcache";
648
- const LOCK_STALE_MS = 2e4;
649
- const LOCK_UPDATE_MS = 3e3;
650
- const METADATA_READ_LIMIT = 4096;
651
- const FiniteNumberSchema = pipe(number(), finite());
652
- const PositionSchema = object({
653
- column: FiniteNumberSchema,
654
- line: FiniteNumberSchema
655
- });
656
- const SourceLocationSchema = object({
657
- end: PositionSchema,
658
- start: PositionSchema
659
- });
660
- const DiagnosticLocationSchema = object({
661
- end: optional(PositionSchema),
662
- start: PositionSchema
663
- });
664
- const SourceRangeSchema = object({
665
- end: FiniteNumberSchema,
666
- start: FiniteNumberSchema
667
- });
668
- const DiagnosticSchema = object({
669
- cached: optional(boolean()),
670
- evidence: optional(custom(isJsonValue)),
671
- filePath: string(),
672
- loc: optional(DiagnosticLocationSchema),
673
- message: string(),
674
- model: optional(object({
675
- providerId: string(),
676
- requested: optional(string()),
677
- resolvedId: string()
678
- })),
679
- ruleId: string(),
680
- severity: union([literal("error"), literal("warn")])
681
- });
682
- const UsageSchema = object({
683
- filePath: optional(string()),
684
- inputTokens: optional(FiniteNumberSchema),
685
- metadata: optional(custom(isJsonValue)),
686
- modelId: string(),
687
- outputTokens: optional(FiniteNumberSchema),
688
- providerId: string(),
689
- ruleId: string(),
690
- totalTokens: optional(FiniteNumberSchema)
691
- });
692
- const CacheEntrySchema = object({
693
- diagnostics: array(DiagnosticSchema),
694
- fingerprint: object({
695
- configHash: string(),
696
- modelHash: string(),
697
- ruleHash: string(),
698
- targetHash: string()
699
- }),
700
- target: object({
701
- hash: string(),
702
- identity: string(),
703
- kind: string(),
704
- loc: optional(SourceLocationSchema),
705
- name: optional(string()),
706
- range: optional(SourceRangeSchema)
707
- }),
708
- usage: array(UsageSchema)
709
- });
710
- const CachedOwnerSchema = object({
711
- contentHash: optional(string()),
712
- kind: union([literal("file"), literal("project")]),
713
- path: string(),
714
- slots: array(string())
715
- });
716
- function objectRecord(value) {
717
- return pipe(unknown(), check((input) => typeof input === "object" && input !== null && !Array.isArray(input)), record(string(), value));
718
- }
719
- const CacheFileBodySchema = pipe(object({
720
- createdAt: string(),
721
- entries: objectRecord(CacheEntrySchema),
722
- owners: objectRecord(CachedOwnerSchema),
723
- updatedAt: string()
724
- }), check((body) => {
725
- const entryKeys = Object.keys(body.entries);
726
- const referencedSlots = Object.values(body.owners).flatMap((owner) => owner.slots);
727
- const uniqueSlots = new Set(referencedSlots);
728
- return uniqueSlots.size === referencedSlots.length && uniqueSlots.size === entryKeys.length && entryKeys.every((key) => uniqueSlots.has(key));
729
- }));
730
- const CacheMetadataSchema = object({
731
- alintVersion: string(),
732
- createdAt: string(),
733
- magic: literal(CACHE_MAGIC),
734
- schemaVersion: literal(2),
735
- type: literal("metadata")
736
- });
737
- const PutEventSchema = object({
738
- at: string(),
739
- entry: CacheEntrySchema,
740
- owner: CachedOwnerSchema,
741
- ownerKey: string(),
742
- slotKey: string(),
743
- type: literal("put")
744
- });
745
- const ReplaceOwnerEventSchema = object({
746
- at: string(),
747
- entries: array(object({
748
- entry: CacheEntrySchema,
749
- slotKey: string()
750
- })),
751
- mode: union([literal("merge"), literal("replace")]),
752
- owner: CachedOwnerSchema,
753
- ownerKey: string(),
754
- type: literal("replace-owner")
755
- });
756
- const RemoveOwnerEventSchema = object({
757
- at: string(),
758
- ownerKey: string(),
759
- type: literal("remove-owner")
760
- });
761
- const CacheEventSchema = union([
762
- PutEventSchema,
763
- ReplaceOwnerEventSchema,
764
- RemoveOwnerEventSchema
765
- ]);
766
- async function createCacheStore(options) {
767
- const location = resolveCacheLocation(options.cwd, options.location);
768
- if (!options.enabled) return createNoopCacheStore(location);
769
- const alintVersion = options.alintVersion ?? version;
770
- const initial = await loadCacheLog(location, alintVersion);
771
- if (options.readOnly) return createReadOnlyCacheStore(location, initial.body, options.cwd);
772
- const dependencies = {
773
- append: options.appendFile ?? appendFile,
774
- lock: options.lock ?? { acquire: lockfile.lock },
775
- write: options.writeFile ?? writeFile
776
- };
777
- const trimmed = await trimOnce(location, alintVersion, initial, dependencies);
778
- const writer = createEventWriter(location, trimmed.metadata, dependencies);
779
- const fileExists = options.fileExists ?? defaultFileExists;
780
- return {
781
- beginOwner: (owner, metadata) => beginOwner(trimmed.body, owner, options.cwd, writer, metadata),
782
- flush: () => writer.flush(false),
783
- location,
784
- reconcile: async () => {
785
- const removedOwners = await collectMissingFileOwners(trimmed.body, options.cwd, fileExists);
786
- for (const ownerKey of removedOwners) writer.enqueue({
787
- at: (/* @__PURE__ */ new Date()).toISOString(),
788
- ownerKey,
789
- type: "remove-owner"
790
- });
791
- await writer.flush(true);
792
- }
793
- };
794
- }
795
- function createTargetIdentityResolver(targets) {
796
- const baseCounts = /* @__PURE__ */ new Map();
797
- const duplicateCandidateCounts = /* @__PURE__ */ new Map();
798
- for (const target of targets) {
799
- const base = createBaseTargetIdentity(target);
800
- baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
801
- }
802
- for (const target of targets) {
803
- const base = createBaseTargetIdentity(target);
804
- if ((baseCounts.get(base) ?? 0) <= 1) continue;
805
- const candidate = target.range ? `${base}:${target.range.start}:${target.range.end}` : base;
806
- duplicateCandidateCounts.set(candidate, (duplicateCandidateCounts.get(candidate) ?? 0) + 1);
807
- }
808
- return (target, targetIndex) => {
809
- const base = createBaseTargetIdentity(target);
810
- if ((baseCounts.get(base) ?? 0) <= 1) return base;
811
- const candidate = target.range ? `${base}:${target.range.start}:${target.range.end}` : base;
812
- if ((duplicateCandidateCounts.get(candidate) ?? 0) <= 1) return candidate;
813
- return `${candidate}:${targetIndex}`;
814
- };
815
- }
816
- function normalizeCachePath(cwd, filePath) {
817
- const resolvedPath = isAbsolute$1(filePath) ? resolve$1(filePath) : resolve$1(cwd, filePath);
818
- return relative$1(cwd, resolvedPath).split(sep).join("/") || ".";
819
- }
820
- function normalizeRunnerCacheConfig(cache, cwd) {
821
- if (cache === false) return {
822
- enabled: false,
823
- location: resolveCacheLocation(cwd)
824
- };
825
- if (cache === true || cache === void 0) return {
826
- enabled: true,
827
- location: resolveCacheLocation(cwd)
828
- };
829
- return {
830
- enabled: cache.enabled ?? true,
831
- location: resolveCacheLocation(cwd, cache.location)
832
- };
833
- }
834
- function resolveCacheLocation(cwd, location) {
835
- if (!location) return join(cwd, DEFAULT_CACHE_FILE_NAME);
836
- const resolved = isAbsolute$1(location) ? resolve$1(location) : resolve$1(cwd, location);
837
- if (location.endsWith("/") || location.endsWith("\\")) return join(resolved, DEFAULT_CACHE_FILE_NAME);
838
- try {
839
- if (statSync(resolved).isDirectory()) return join(resolved, DEFAULT_CACHE_FILE_NAME);
840
- } catch {}
841
- return resolved;
842
- }
843
- async function appendBatch(location, payload, append) {
844
- const handle = await open(location, "a+");
845
- try {
846
- const { size } = await handle.stat();
847
- let separator = "";
848
- if (size > 0) {
849
- const finalByte = Buffer.alloc(1);
850
- await handle.read(finalByte, 0, 1, size - 1);
851
- separator = finalByte[0] === 10 ? "" : "\n";
852
- }
853
- await append(location, `${separator}${payload}`, "utf8");
854
- } finally {
855
- await handle.close();
856
- }
857
- }
858
- function applyEvent(body, event) {
859
- if (event.type === "remove-owner") {
860
- removeOwner(body, event.ownerKey);
861
- body.updatedAt = event.at;
862
- return;
863
- }
864
- if (event.type === "put") {
865
- const current = body.owners[event.ownerKey];
866
- const slots = new Set(current?.slots ?? []);
867
- slots.add(event.slotKey);
868
- body.entries[event.slotKey] = event.entry;
869
- body.owners[event.ownerKey] = {
870
- ...event.owner,
871
- slots: [...slots].sort()
872
- };
873
- body.updatedAt = event.at;
874
- return;
875
- }
876
- const entries = event.mode === "merge" ? ownerEntries(body, body.owners[event.ownerKey]) : /* @__PURE__ */ new Map();
877
- for (const item of event.entries) entries.set(item.slotKey, item.entry);
878
- for (const slot of body.owners[event.ownerKey]?.slots ?? []) delete body.entries[slot];
879
- for (const [slotKey, entry] of entries) body.entries[slotKey] = entry;
880
- body.owners[event.ownerKey] = {
881
- ...event.owner,
882
- slots: [...entries.keys()].sort()
883
- };
884
- body.updatedAt = event.at;
885
- }
886
- function beginOwner(body, owner, cwd, writer, ownerMetadata = {}) {
887
- const normalizedOwner = {
888
- kind: owner.kind,
889
- path: normalizeCachePath(cwd, owner.path)
890
- };
891
- const key = ownerKey(normalizedOwner, cwd);
892
- const previousOwner = body.owners[key];
893
- const nextEntries = /* @__PURE__ */ new Map();
894
- const checkpointEntries = /* @__PURE__ */ new Map();
895
- const commit = (metadata = {}) => {
896
- const contentHash = metadata.contentHash ?? ownerMetadata.contentHash;
897
- const committedEntries = metadata.mode === "merge" ? ownerEntries(body, body.owners[key]) : /* @__PURE__ */ new Map();
898
- for (const [entryKey, cacheEntry] of nextEntries) committedEntries.set(entryKey, cacheEntry);
899
- const replacedSlots = /* @__PURE__ */ new Set([...previousOwner?.slots ?? [], ...body.owners[key]?.slots ?? []]);
900
- for (const replacedSlot of replacedSlots) delete body.entries[replacedSlot];
901
- for (const [entryKey, cacheEntry] of committedEntries) body.entries[entryKey] = cacheEntry;
902
- const cachedOwner = {
903
- contentHash,
904
- kind: normalizedOwner.kind,
905
- path: normalizedOwner.path,
906
- slots: [...committedEntries.keys()].sort()
907
- };
908
- body.owners[key] = cachedOwner;
909
- body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
910
- writer.enqueue({
911
- at: body.updatedAt,
912
- entries: [...committedEntries].map(([slotKey, entry]) => ({
913
- entry,
914
- slotKey
915
- })),
916
- mode: metadata.mode ?? "replace",
917
- owner: cachedOwner,
918
- ownerKey: key,
919
- type: "replace-owner"
920
- });
921
- checkpointEntries.clear();
922
- };
923
- return {
924
- checkpoint: async () => {
925
- const cachedOwner = {
926
- contentHash: ownerMetadata.contentHash,
927
- kind: normalizedOwner.kind,
928
- path: normalizedOwner.path,
929
- slots: []
930
- };
931
- for (const [entryKey, cacheEntry] of checkpointEntries) {
932
- const event = {
933
- at: (/* @__PURE__ */ new Date()).toISOString(),
934
- entry: cacheEntry,
935
- owner: cachedOwner,
936
- ownerKey: key,
937
- slotKey: entryKey,
938
- type: "put"
939
- };
940
- applyEvent(body, event);
941
- writer.enqueue(event);
942
- }
943
- checkpointEntries.clear();
944
- await writer.flush(false);
945
- },
946
- commit,
947
- discard: (cacheSlot) => {
948
- const entryKey = slotKey(normalizedOwner, cacheSlot, cwd);
949
- nextEntries.delete(entryKey);
950
- checkpointEntries.delete(entryKey);
951
- },
952
- lookup: (cacheSlot, fingerprint) => {
953
- const entryKey = slotKey(normalizedOwner, cacheSlot, cwd);
954
- const cached = body.entries[entryKey];
955
- if (!cached || !fingerprintsEqual(cached.fingerprint, fingerprint)) return void 0;
956
- nextEntries.set(entryKey, cached);
957
- return cached;
958
- },
959
- put: (cacheSlot, cacheEntry) => {
960
- const entryKey = slotKey(normalizedOwner, cacheSlot, cwd);
961
- nextEntries.set(entryKey, cacheEntry);
962
- checkpointEntries.set(entryKey, cacheEntry);
963
- }
964
- };
965
- }
966
- async function collectMissingFileOwners(body, cwd, fileExists) {
967
- const removed = [];
968
- for (const [key, owner] of Object.entries(body.owners)) {
969
- if (owner.kind !== "file" || await fileExists(resolve$1(cwd, owner.path))) continue;
970
- removeOwner(body, key);
971
- removed.push(key);
972
- }
973
- return removed;
974
- }
975
- function createBaseTargetIdentity(target) {
976
- if (target.identity && (target.kind !== "file" || target.identity !== "file")) return target.filePath ? `${target.kind}:${target.filePath}:${target.identity}` : `${target.kind}:${target.identity}`;
977
- if (target.kind === "file") return target.filePath ? `file:${target.filePath}` : "file";
978
- if (target.name) return `${target.kind}:${target.name}`;
979
- if (target.range) return `${target.kind}:${target.range.start}:${target.range.end}`;
980
- return target.kind;
981
- }
982
- function createEmptyCacheBody(createdAt = (/* @__PURE__ */ new Date()).toISOString()) {
983
- return {
984
- createdAt,
985
- entries: {},
986
- owners: {},
987
- updatedAt: createdAt
988
- };
989
- }
990
- function createEventWriter(location, metadata, dependencies) {
991
- const backlog = [];
992
- let validationError;
993
- let previousWrite = Promise.resolve();
994
- const persistBacklog = async () => {
995
- if (validationError) throw validationError;
996
- if (backlog.length === 0) return;
997
- await mkdir(dirname(location), { recursive: true });
998
- await withCacheLock(location, dependencies.lock, getCacheLockOptions({
999
- factor: 2,
1000
- maxTimeout: 16e3,
1001
- minTimeout: 2e3,
1002
- retries: 4
1003
- }), async () => {
1004
- await ensureCompatibleMetadata(location, metadata, dependencies.write);
1005
- const batchSize = backlog.length;
1006
- await appendBatch(location, backlog.slice(0, batchSize).map((event) => `${JSON.stringify(event)}\n`).join(""), dependencies.append);
1007
- backlog.splice(0, batchSize);
1008
- });
1009
- validationError = void 0;
1010
- };
1011
- return {
1012
- enqueue: (event) => {
1013
- try {
1014
- backlog.push(parse(CacheEventSchema, event));
1015
- } catch (error) {
1016
- validationError ??= error;
1017
- }
1018
- },
1019
- flush: (finalAttempt) => {
1020
- const write = previousWrite.then(persistBacklog);
1021
- previousWrite = write.catch(() => {});
1022
- return finalAttempt ? write : write.catch(() => {});
1023
- }
1024
- };
1025
- }
1026
- function createNoopCacheStore(location) {
1027
- const transaction = {
1028
- checkpoint: async () => {},
1029
- commit: () => {},
1030
- discard: () => {},
1031
- lookup: () => void 0,
1032
- put: () => {}
1033
- };
1034
- return {
1035
- beginOwner: () => transaction,
1036
- flush: async () => {},
1037
- location,
1038
- reconcile: async () => {}
1039
- };
1040
- }
1041
- function createReadOnlyCacheStore(location, body, cwd) {
1042
- return {
1043
- beginOwner: (owner) => ({
1044
- checkpoint: async () => {},
1045
- commit: () => {},
1046
- discard: () => {},
1047
- lookup: (slot, fingerprint) => {
1048
- const cached = body.entries[slotKey(owner, slot, cwd)];
1049
- return cached && fingerprintsEqual(cached.fingerprint, fingerprint) ? cached : void 0;
1050
- },
1051
- put: () => {}
1052
- }),
1053
- flush: async () => {},
1054
- location,
1055
- reconcile: async () => {}
1056
- };
1057
- }
1058
- async function defaultFileExists(path) {
1059
- try {
1060
- await access(path);
1061
- return true;
1062
- } catch (error) {
1063
- if (isMissingFileError(error)) return false;
1064
- throw error;
1065
- }
1066
- }
1067
- function emptyLoadedLog(alintVersion) {
1068
- const metadata = {
1069
- alintVersion,
1070
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1071
- magic: CACHE_MAGIC,
1072
- schemaVersion: 2,
1073
- type: "metadata"
1074
- };
1075
- return {
1076
- body: createEmptyCacheBody(metadata.createdAt),
1077
- metadata
1078
- };
1079
- }
1080
- async function ensureCompatibleMetadata(location, metadata, write) {
1081
- try {
1082
- if (parseMetadataLine(await readMetadataLine(location), metadata.alintVersion)) return;
1083
- } catch (error) {
1084
- if (!isMissingFileError(error)) throw error;
1085
- }
1086
- await write(location, `${JSON.stringify(metadata)}\n`);
1087
- }
1088
- function fingerprintsEqual(left, right) {
1089
- return left.configHash === right.configHash && left.modelHash === right.modelHash && left.ruleHash === right.ruleHash && left.targetHash === right.targetHash;
1090
- }
1091
- function getCacheLockOptions(retries) {
1092
- return {
1093
- realpath: false,
1094
- retries,
1095
- stale: LOCK_STALE_MS,
1096
- update: LOCK_UPDATE_MS
1097
- };
1098
- }
1099
- function hasJsonArrayProperties(input, ancestors) {
1100
- let arrayLength;
1101
- let indexCount = 0;
1102
- for (const key of Reflect.ownKeys(input)) {
1103
- const descriptor = Object.getOwnPropertyDescriptor(input, key);
1104
- if (!descriptor || !("value" in descriptor)) return false;
1105
- if (key === "length") {
1106
- if (descriptor.enumerable || typeof descriptor.value !== "number") return false;
1107
- arrayLength = descriptor.value;
1108
- continue;
1109
- }
1110
- const index = typeof key === "string" ? Number(key) : NaN;
1111
- if (typeof key !== "string" || !Number.isInteger(index) || index < 0 || index >= 2 ** 32 - 1 || String(index) !== key || !descriptor.enumerable) return false;
1112
- if (!isJsonValueAt(descriptor.value, ancestors)) return false;
1113
- indexCount += 1;
732
+ function hasJsonArrayProperties(input, ancestors) {
733
+ let arrayLength;
734
+ let indexCount = 0;
735
+ for (const key of Reflect.ownKeys(input)) {
736
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
737
+ if (!descriptor || !("value" in descriptor)) return false;
738
+ if (key === "length") {
739
+ if (descriptor.enumerable || typeof descriptor.value !== "number") return false;
740
+ arrayLength = descriptor.value;
741
+ continue;
742
+ }
743
+ const index = typeof key === "string" ? Number(key) : NaN;
744
+ if (typeof key !== "string" || !Number.isInteger(index) || index < 0 || index >= 2 ** 32 - 1 || String(index) !== key || !descriptor.enumerable) return false;
745
+ if (!isJsonValueAt(descriptor.value, ancestors)) return false;
746
+ indexCount += 1;
1114
747
  }
1115
748
  return arrayLength !== void 0 && indexCount === arrayLength;
1116
749
  }
@@ -1145,141 +778,509 @@ function isJsonValueAt(input, ancestors) {
1145
778
  ancestors.delete(input);
1146
779
  }
1147
780
  }
1148
- function isMissingFileError(error) {
1149
- return isNodeErrorCode(error, "ENOENT") || isNodeErrorCode(error, "ENOTDIR");
781
+ function isMissingFileError(error) {
782
+ return isNodeErrorCode(error, "ENOENT") || isNodeErrorCode(error, "ENOTDIR");
783
+ }
784
+ async function loadCacheLog(location, alintVersion) {
785
+ try {
786
+ const metadata = parseMetadataLine(await readMetadataLine(location), alintVersion);
787
+ if (!metadata) return emptyLoadedLog(alintVersion);
788
+ return replayLog(await readFile(location, "utf8"), metadata);
789
+ } catch (error) {
790
+ if (isMissingFileError(error)) return emptyLoadedLog(alintVersion);
791
+ throw error;
792
+ }
793
+ }
794
+ function ownerEntries(body, owner) {
795
+ const entries = /* @__PURE__ */ new Map();
796
+ for (const slot of owner?.slots ?? []) {
797
+ const cacheEntry = body.entries[slot];
798
+ if (cacheEntry) entries.set(slot, cacheEntry);
799
+ }
800
+ return entries;
801
+ }
802
+ function ownerKey(owner, cwd) {
803
+ return stableHash({
804
+ kind: owner.kind,
805
+ path: normalizeCachePath(cwd, owner.path)
806
+ });
807
+ }
808
+ function parseMetadataLine(line, expectedAlintVersion) {
809
+ if (!line) return void 0;
810
+ try {
811
+ const value = JSON.parse(line);
812
+ const metadata = parse(CacheMetadataSchema, value);
813
+ if (expectedAlintVersion !== void 0 && metadata.alintVersion !== expectedAlintVersion) return void 0;
814
+ return metadata;
815
+ } catch {
816
+ return;
817
+ }
818
+ }
819
+ async function persistCompactedLog(location, loaded, write) {
820
+ const metadata = parse(CacheMetadataSchema, loaded.metadata);
821
+ const events = Object.entries(loaded.body.owners).map(([ownerKey, owner]) => ({
822
+ at: loaded.body.updatedAt,
823
+ entries: [...ownerEntries(loaded.body, owner)].map(([slotKey, entry]) => ({
824
+ entry,
825
+ slotKey
826
+ })),
827
+ mode: "replace",
828
+ owner,
829
+ ownerKey,
830
+ type: "replace-owner"
831
+ })).map((event) => parse(CacheEventSchema, event));
832
+ const contents = [`${JSON.stringify(metadata)}\n`, ...events.map((event) => `${JSON.stringify(event)}\n`)].join("");
833
+ const tempPath = join(dirname(location), `.${basename$1(location)}.${process.pid}.${randomUUID()}.tmp`);
834
+ try {
835
+ await write(tempPath, contents);
836
+ await rename(tempPath, location);
837
+ } catch (error) {
838
+ await rm(tempPath, { force: true }).catch(() => {});
839
+ throw error;
840
+ }
841
+ }
842
+ async function readMetadataLine(location) {
843
+ const handle = await open(location, "r");
844
+ try {
845
+ const buffer = Buffer.alloc(METADATA_READ_LIMIT);
846
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
847
+ const newlineIndex = buffer.subarray(0, bytesRead).indexOf(10);
848
+ if (newlineIndex < 0) return void 0;
849
+ const lineEnd = newlineIndex > 0 && buffer[newlineIndex - 1] === 13 ? newlineIndex - 1 : newlineIndex;
850
+ return buffer.toString("utf8", 0, lineEnd);
851
+ } finally {
852
+ await handle.close();
853
+ }
854
+ }
855
+ function removeOwner(body, key) {
856
+ for (const slot of body.owners[key]?.slots ?? []) delete body.entries[slot];
857
+ delete body.owners[key];
858
+ }
859
+ function replayLog(text, metadata) {
860
+ const body = createEmptyCacheBody(metadata.createdAt);
861
+ const lines = text.split(/\r?\n/);
862
+ for (let index = 1; index < lines.length; index += 1) {
863
+ const line = lines[index];
864
+ if (!line) continue;
865
+ try {
866
+ const value = JSON.parse(line);
867
+ applyEvent(body, parse(CacheEventSchema, value));
868
+ } catch {}
869
+ }
870
+ return {
871
+ body: parse(CacheFileBodySchema, body),
872
+ metadata
873
+ };
874
+ }
875
+ function slotKey(owner, slot, cwd) {
876
+ return stableHash({
877
+ owner: ownerKey(owner, cwd),
878
+ ...slot
879
+ });
880
+ }
881
+ async function trimOnce(location, alintVersion, fallback, dependencies) {
882
+ await mkdir(dirname(location), { recursive: true });
883
+ try {
884
+ return await withCacheLock(location, dependencies.lock, getCacheLockOptions(0), async () => {
885
+ const latest = await loadCacheLog(location, alintVersion);
886
+ await persistCompactedLog(location, latest, dependencies.write);
887
+ return latest;
888
+ });
889
+ } catch (error) {
890
+ if (isNodeErrorCode(error, "ELOCKED")) return fallback;
891
+ throw error;
892
+ }
893
+ }
894
+ async function withCacheLock(location, lock, options, operation) {
895
+ let compromised;
896
+ const release = await lock.acquire(location, {
897
+ ...options,
898
+ onCompromised: (error) => {
899
+ compromised = error;
900
+ }
901
+ });
902
+ let operationError;
903
+ try {
904
+ const result = await operation();
905
+ if (compromised) throw compromised;
906
+ return result;
907
+ } catch (error) {
908
+ operationError = error;
909
+ throw error;
910
+ } finally {
911
+ try {
912
+ await release();
913
+ } catch (error) {
914
+ if (operationError === void 0) throw error;
915
+ }
916
+ }
917
+ }
918
+ //#endregion
919
+ //#region src/core/languages/js/index.ts
920
+ const javascriptLanguage = {
921
+ extensions: [
922
+ ".cjs",
923
+ ".js",
924
+ ".jsx",
925
+ ".mjs"
926
+ ],
927
+ extract: (file) => extractJsSourceTargets(withLanguage(file, "javascript")),
928
+ name: "javascript"
929
+ };
930
+ //#endregion
931
+ //#region src/core/languages/registry.ts
932
+ function createLanguageRegistry$1() {
933
+ return {
934
+ byExtension: /* @__PURE__ */ new Map(),
935
+ languages: /* @__PURE__ */ new Map()
936
+ };
937
+ }
938
+ function registerLanguage(registry, language) {
939
+ const existing = registry.languages.get(language.name);
940
+ if (existing && existing !== language) throw new Error(`Duplicate language "${language.name}".`);
941
+ for (const extension of language.extensions ?? []) {
942
+ const existingOwner = registry.byExtension.get(extension);
943
+ if (existingOwner && existingOwner !== language.name) throw new Error(`Duplicate language extension "${extension}".`);
944
+ }
945
+ registry.languages.set(language.name, language);
946
+ for (const extension of language.extensions ?? []) registry.byExtension.set(extension, language.name);
947
+ }
948
+ //#endregion
949
+ //#region src/core/languages/text.ts
950
+ const textLanguage = {
951
+ extensions: [],
952
+ extract: (file) => [{
953
+ file,
954
+ identity: "file",
955
+ kind: "file",
956
+ language: "plaintext",
957
+ origin: { physicalPath: file.path },
958
+ text: file.text
959
+ }],
960
+ name: "plaintext"
961
+ };
962
+ //#endregion
963
+ //#region src/core/languages/ts/index.ts
964
+ const typescriptLanguage = {
965
+ extensions: [
966
+ ".cts",
967
+ ".mts",
968
+ ".ts",
969
+ ".tsx"
970
+ ],
971
+ extract: (file) => extractJsSourceTargets(withLanguage(file, "typescript")),
972
+ name: "typescript"
973
+ };
974
+ //#endregion
975
+ //#region src/core/languages/resolve.ts
976
+ function resolveLanguage(file, registry, options) {
977
+ return resolveLanguageForPath(file.path, registry, options);
978
+ }
979
+ function resolveLanguageForPath(filePath, registry, options) {
980
+ const languageName = options.language ?? options.processedLanguage ?? registry.byExtension.get(extname$1(filePath)) ?? "plaintext";
981
+ const language = registry.languages.get(languageName);
982
+ if (!language) {
983
+ const registered = [...registry.languages.keys()].sort().join(", ");
984
+ throw new Error(`Unknown language "${languageName}". Languages come from plugins, and these are registered: ${registered}.`);
985
+ }
986
+ return language;
987
+ }
988
+ //#endregion
989
+ //#region src/core/languages/index.ts
990
+ function createBuiltInLanguageRegistry() {
991
+ const registry = createLanguageRegistry$1();
992
+ registerLanguage(registry, textLanguage);
993
+ registerLanguage(registry, javascriptLanguage);
994
+ registerLanguage(registry, typescriptLanguage);
995
+ return registry;
996
+ }
997
+ //#endregion
998
+ //#region src/dsl/registry.ts
999
+ function buildRuleRegistry(config) {
1000
+ const rules = /* @__PURE__ */ new Map();
1001
+ const localIds = /* @__PURE__ */ new Map();
1002
+ const enabledRules = [];
1003
+ for (const [alias, plugin] of Object.entries(config.plugins)) for (const [localId, rule] of Object.entries(plugin.rules ?? {})) {
1004
+ const id = `${alias}/${localId}`;
1005
+ if (rules.has(id)) throw new Error(`Duplicate rule id "${id}".`);
1006
+ rules.set(id, rule);
1007
+ localIds.set(id, localId);
1008
+ }
1009
+ for (const [id, entry] of Object.entries(config.rules)) {
1010
+ const rule = rules.get(id);
1011
+ if (!rule) throw new Error(`Unknown rule "${id}".`);
1012
+ const normalizedEntry = normalizeRuleConfigEntry(entry);
1013
+ const severity = normalizedEntry.severity;
1014
+ if (severity === "off") continue;
1015
+ enabledRules.push({
1016
+ id,
1017
+ localId: localIds.get(id) ?? id,
1018
+ options: parseRuleOptions(id, rule.options, normalizedEntry.options),
1019
+ rule,
1020
+ severity
1021
+ });
1022
+ }
1023
+ return {
1024
+ enabledRules,
1025
+ rules
1026
+ };
1027
+ }
1028
+ function formatRuleOptionIssues(index, issues) {
1029
+ return issues.map((issue) => {
1030
+ const path = getDotPath(issue);
1031
+ return path === null ? `"${index}": ${issue.message}` : `"${index}.${path}": ${issue.message}`;
1032
+ }).join("; ");
1150
1033
  }
1151
- async function loadCacheLog(location, alintVersion) {
1152
- try {
1153
- const metadata = parseMetadataLine(await readMetadataLine(location), alintVersion);
1154
- if (!metadata) return emptyLoadedLog(alintVersion);
1155
- return replayLog(await readFile(location, "utf8"), metadata);
1156
- } catch (error) {
1157
- if (isMissingFileError(error)) return emptyLoadedLog(alintVersion);
1158
- throw error;
1034
+ function isRuleConfigTuple(entry) {
1035
+ return Array.isArray(entry);
1036
+ }
1037
+ function normalizeRuleConfigEntry(entry) {
1038
+ if (isRuleConfigTuple(entry)) {
1039
+ const [severity = "warn", ...options] = entry;
1040
+ return {
1041
+ options,
1042
+ severity
1043
+ };
1159
1044
  }
1045
+ return {
1046
+ options: [],
1047
+ severity: entry ?? "warn"
1048
+ };
1160
1049
  }
1161
- function ownerEntries(body, owner) {
1162
- const entries = /* @__PURE__ */ new Map();
1163
- for (const slot of owner?.slots ?? []) {
1164
- const cacheEntry = body.entries[slot];
1165
- if (cacheEntry) entries.set(slot, cacheEntry);
1050
+ function parseRuleOption(id, index, schema, value) {
1051
+ const result = safeParse(schema, value);
1052
+ if (result.success) return result.output;
1053
+ if (value === void 0) {
1054
+ const emptyObjectResult = safeParse(schema, {});
1055
+ if (emptyObjectResult.success) return emptyObjectResult.output;
1166
1056
  }
1167
- return entries;
1057
+ throw new TypeError(`Invalid options for rule "${id}": ${formatRuleOptionIssues(index, result.issues)}`);
1168
1058
  }
1169
- function ownerKey(owner, cwd) {
1170
- return stableHash({
1171
- kind: owner.kind,
1172
- path: normalizeCachePath(cwd, owner.path)
1059
+ function parseRuleOptions(id, schema, options) {
1060
+ if (!schema) {
1061
+ if (options.length > 0) throw new TypeError(`Rule "${id}" does not accept options.`);
1062
+ return [];
1063
+ }
1064
+ if (options.length > schema.length) throw new TypeError(`Invalid options for rule "${id}": Unexpected option at index ${schema.length}.`);
1065
+ return schema.map((optionSchema, index) => parseRuleOption(id, index, optionSchema, options[index]));
1066
+ }
1067
+ //#endregion
1068
+ //#region src/core/languages/diagnostics.ts
1069
+ /** A rule named a language and nothing registered it. This is a builtin rule. Not a real rule. */
1070
+ const MISSING_LANGUAGE_RULE = "alint/missing-language";
1071
+ /** A linted file's extension belongs to no registered language. This is a builtin rule. Not a real rule either. */
1072
+ const UNREGISTERED_LANGUAGE_RULE = "alint/unregistered-language";
1073
+ /**
1074
+ * One diagnostic per missing language, always an error: a rule scoped to a language nothing provides
1075
+ * matches no file, so leaving it at a warning would let a run pass having silently checked nothing.
1076
+ */
1077
+ function missingLanguageDiagnostics(missing) {
1078
+ return [...missing.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([languageId, entry]) => {
1079
+ const ruleIds = [...entry.ruleIds].sort();
1080
+ const subject = ruleIds.length === 1 ? `Rule "${ruleIds[0]}" needs` : `Rules ${ruleIds.map((id) => `"${id}"`).join(", ")} need`;
1081
+ return {
1082
+ filePath: entry.filePath,
1083
+ message: `${subject} the "${languageId}" language, which no plugin registered. Add a plugin that provides "${languageId}", or turn the rule off.`,
1084
+ ruleId: MISSING_LANGUAGE_RULE,
1085
+ severity: "error"
1086
+ };
1173
1087
  });
1174
1088
  }
1175
- function parseMetadataLine(line, expectedAlintVersion) {
1176
- if (!line) return void 0;
1177
- try {
1178
- const value = JSON.parse(line);
1179
- const metadata = parse(CacheMetadataSchema, value);
1180
- if (expectedAlintVersion !== void 0 && metadata.alintVersion !== expectedAlintVersion) return void 0;
1181
- return metadata;
1182
- } catch {
1089
+ /** Records one rule that named a language the config never registered, grouped by language id. */
1090
+ function recordMissingLanguage(into, languageId, ruleId, path) {
1091
+ const existing = into.get(languageId);
1092
+ if (existing === void 0) {
1093
+ into.set(languageId, {
1094
+ filePath: path,
1095
+ ruleIds: /* @__PURE__ */ new Set([ruleId])
1096
+ });
1183
1097
  return;
1184
1098
  }
1099
+ existing.ruleIds.add(ruleId);
1100
+ if (path < existing.filePath) existing.filePath = path;
1185
1101
  }
1186
- async function persistCompactedLog(location, loaded, write) {
1187
- const metadata = parse(CacheMetadataSchema, loaded.metadata);
1188
- const events = Object.entries(loaded.body.owners).map(([ownerKey, owner]) => ({
1189
- at: loaded.body.updatedAt,
1190
- entries: [...ownerEntries(loaded.body, owner)].map(([slotKey, entry]) => ({
1191
- entry,
1192
- slotKey
1193
- })),
1194
- mode: "replace",
1195
- owner,
1196
- ownerKey,
1197
- type: "replace-owner"
1198
- })).map((event) => parse(CacheEventSchema, event));
1199
- const contents = [`${JSON.stringify(metadata)}\n`, ...events.map((event) => `${JSON.stringify(event)}\n`)].join("");
1200
- const tempPath = join(dirname(location), `.${basename$1(location)}.${process.pid}.${randomUUID()}.tmp`);
1201
- try {
1202
- await write(tempPath, contents);
1203
- await rename(tempPath, location);
1204
- } catch (error) {
1205
- await rm(tempPath, { force: true }).catch(() => {});
1206
- throw error;
1207
- }
1102
+ /**
1103
+ * Records one linted file no language claimed, grouped by extension so a missing pack is one
1104
+ * diagnostic, not one per file. `'off'` records nothing.
1105
+ */
1106
+ function recordUnregistered(into, path, severity) {
1107
+ if (severity === "off") return;
1108
+ const key = extname(path) || basename(path);
1109
+ const existing = into.get(key);
1110
+ into.set(key, {
1111
+ count: (existing?.count ?? 0) + 1,
1112
+ filePath: existing !== void 0 && existing.filePath < path ? existing.filePath : path,
1113
+ severity: existing?.severity === "error" ? "error" : severity
1114
+ });
1208
1115
  }
1209
- async function readMetadataLine(location) {
1210
- const handle = await open(location, "r");
1211
- try {
1212
- const buffer = Buffer.alloc(METADATA_READ_LIMIT);
1213
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
1214
- const newlineIndex = buffer.subarray(0, bytesRead).indexOf(10);
1215
- if (newlineIndex < 0) return void 0;
1216
- const lineEnd = newlineIndex > 0 && buffer[newlineIndex - 1] === 13 ? newlineIndex - 1 : newlineIndex;
1217
- return buffer.toString("utf8", 0, lineEnd);
1218
- } finally {
1219
- await handle.close();
1116
+ /** One diagnostic per extension, sorted, for the run to prepend to its results. */
1117
+ function unregisteredLanguageDiagnostics(unregistered) {
1118
+ return [...unregistered.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([extension, entry]) => ({
1119
+ filePath: entry.filePath,
1120
+ message: `No language is registered for "${extension}": ${entry.count} ${entry.count === 1 ? "file was" : "files were"} handled as plain text. Register one through a plugin's "languages", or pin \`language: 'plaintext'\` to accept plain text.`,
1121
+ ruleId: UNREGISTERED_LANGUAGE_RULE,
1122
+ severity: entry.severity
1123
+ }));
1124
+ }
1125
+ function unregisteredLanguageSeverity(linterOptions) {
1126
+ const value = linterOptions.reportUnregisteredLanguages;
1127
+ return value === "error" || value === "off" ? value : "warn";
1128
+ }
1129
+ //#endregion
1130
+ //#region src/core/languages/rule-languages.ts
1131
+ /**
1132
+ * The language a file falls back to when no registered language claims its extension. Not a real
1133
+ * language, which is why `'any'` excludes it.
1134
+ */
1135
+ const PLAIN_TEXT = "plaintext";
1136
+ /**
1137
+ * Whether a rule reads this target.
1138
+ *
1139
+ * A rule with language off still reads file targets, because reading raw text, or asking a model about a
1140
+ * whole file, needs no language. However, it never reads the function and class targets extraction produces.
1141
+ * `'file'` is the one kind every language yields, plain text included, so it is the dividing line.
1142
+ *
1143
+ * `'any'` means any real language, so a file that fell back to plain text because nothing claimed
1144
+ * its extension is withheld: the rule asked for structure and there is none to give. The run says so
1145
+ * once per extension through `alint/unregistered-language` rather than letting each rule report an
1146
+ * empty file.
1147
+ */
1148
+ function isTargetLanguageAccepted(languages, targetKind, targetLanguage) {
1149
+ if (languages.kind === "off") return targetKind === "file";
1150
+ if (languages.kind === "any") return targetLanguage !== PLAIN_TEXT;
1151
+ return languages.ids.includes(targetLanguage);
1152
+ }
1153
+ function resolveRuleLanguages(languages) {
1154
+ if (languages === void 0) return { kind: "off" };
1155
+ if (languages === "any") return { kind: "any" };
1156
+ if ("ids" in languages) return {
1157
+ ids: languages.ids,
1158
+ kind: "list",
1159
+ skipMissing: languages.skipMissing ?? false
1160
+ };
1161
+ return {
1162
+ ids: languages,
1163
+ kind: "list",
1164
+ skipMissing: false
1165
+ };
1166
+ }
1167
+ //#endregion
1168
+ //#region src/core/preparation.ts
1169
+ /**
1170
+ * The `ctx.src.extract` a run hands to rules, for parsing files it was never asked to lint, which
1171
+ * an index builder sweeping the workspace needs.
1172
+ *
1173
+ * It resolves each file's OWN config rather than a caller's, because the language a file resolves to
1174
+ * is a config decision and two config groups may register different plugins. It extracts on demand
1175
+ * and holds nothing: source planning releases rich extractor values after compact jobs are admitted,
1176
+ * and a run-wide parse memo here would put that retention back. A caller keeps only what it derives.
1177
+ *
1178
+ * `getSrc` defers reading the runtime because the runtime is built around this closure. It is assigned
1179
+ * before any rule runs, so by the first call it is present.
1180
+ */
1181
+ function createSourceExtractor(cwd, config, getSrc) {
1182
+ return async (filePath, options = {}) => {
1183
+ const path = resolve(cwd, filePath);
1184
+ const resolvedConfig = resolveConfigForFile(path, config, { cwd });
1185
+ if (resolvedConfig.ignored) return [];
1186
+ const effectiveConfig = resolvedConfig.config;
1187
+ const language = resolveLanguageForPath(path, createLanguageRegistry(effectiveConfig), { language: options.language ?? effectiveConfig.language });
1188
+ const src = getSrc();
1189
+ const file = await src.readFile(path);
1190
+ return language.extract(file, {
1191
+ cwd,
1192
+ languageOptions: effectiveConfig.languageOptions,
1193
+ src
1194
+ });
1195
+ };
1196
+ }
1197
+ function prepareRun(options = {}) {
1198
+ const cwd$2 = options.cwd ?? cwd();
1199
+ const config = options.config ?? [];
1200
+ const files = [];
1201
+ const directories = [];
1202
+ const missingLanguages = /* @__PURE__ */ new Map();
1203
+ const unregisteredLanguages = /* @__PURE__ */ new Map();
1204
+ for (const filePath of options.files ?? []) {
1205
+ const path = resolve(cwd$2, filePath);
1206
+ const resolvedConfig = resolveConfigForFile(path, config, { cwd: cwd$2 });
1207
+ if (resolvedConfig.ignored) continue;
1208
+ const effectiveConfig = resolvedConfig.config;
1209
+ const languageRegistry = createLanguageRegistry(effectiveConfig);
1210
+ const language = resolveLanguageForPath(path, languageRegistry, { language: effectiveConfig.language });
1211
+ const rules = prepareRules(effectiveConfig);
1212
+ recordMissingLanguages(missingLanguages, rules, languageRegistry, path);
1213
+ if (effectiveConfig.language === void 0 && language.name === "plaintext" && rules.some(({ enabledRule }) => !isTargetLanguageAccepted(resolveRuleLanguages(enabledRule.rule.languages), "file", "plaintext"))) recordUnregistered(unregisteredLanguages, path, unregisteredLanguageSeverity(effectiveConfig.linterOptions));
1214
+ files.push({
1215
+ agent: effectiveConfig.agent,
1216
+ configHash: stableHash({
1217
+ language: effectiveConfig.language,
1218
+ languageOptions: effectiveConfig.languageOptions,
1219
+ processor: effectiveConfig.processor,
1220
+ resolvedLanguage: language.name,
1221
+ settings: effectiveConfig.settings
1222
+ }),
1223
+ fileIndex: files.length,
1224
+ language,
1225
+ languageOptions: effectiveConfig.languageOptions,
1226
+ path,
1227
+ rules,
1228
+ settings: effectiveConfig.settings
1229
+ });
1220
1230
  }
1221
- }
1222
- function removeOwner(body, key) {
1223
- for (const slot of body.owners[key]?.slots ?? []) delete body.entries[slot];
1224
- delete body.owners[key];
1225
- }
1226
- function replayLog(text, metadata) {
1227
- const body = createEmptyCacheBody(metadata.createdAt);
1228
- const lines = text.split(/\r?\n/);
1229
- for (let index = 1; index < lines.length; index += 1) {
1230
- const line = lines[index];
1231
- if (!line) continue;
1232
- try {
1233
- const value = JSON.parse(line);
1234
- applyEvent(body, parse(CacheEventSchema, value));
1235
- } catch {}
1231
+ for (const directoryPath of options.directories ?? []) {
1232
+ const path = resolve(cwd$2, directoryPath);
1233
+ const resolvedConfig = resolveConfigForDirectory(path, config, { cwd: cwd$2 });
1234
+ if (resolvedConfig.ignored) continue;
1235
+ const effectiveConfig = resolvedConfig.config;
1236
+ directories.push({
1237
+ agent: effectiveConfig.agent,
1238
+ configHash: stableHash({ settings: effectiveConfig.settings }),
1239
+ directoryIndex: directories.length,
1240
+ rules: prepareRules(effectiveConfig),
1241
+ settings: effectiveConfig.settings,
1242
+ target: {
1243
+ kind: "directory",
1244
+ path
1245
+ }
1246
+ });
1236
1247
  }
1237
1248
  return {
1238
- body: parse(CacheFileBodySchema, body),
1239
- metadata
1249
+ directories,
1250
+ files,
1251
+ missingLanguages,
1252
+ project: options.projectTargets === false ? void 0 : prepareProject(cwd$2, config),
1253
+ unregisteredLanguages
1240
1254
  };
1241
1255
  }
1242
- function slotKey(owner, slot, cwd) {
1243
- return stableHash({
1244
- owner: ownerKey(owner, cwd),
1245
- ...slot
1246
- });
1256
+ function createLanguageRegistry(config) {
1257
+ const registry = createBuiltInLanguageRegistry();
1258
+ for (const plugin of Object.values(config.plugins)) for (const language of Object.values(plugin.languages ?? {})) registerLanguage(registry, language);
1259
+ return registry;
1247
1260
  }
1248
- async function trimOnce(location, alintVersion, fallback, dependencies) {
1249
- await mkdir(dirname(location), { recursive: true });
1250
- try {
1251
- return await withCacheLock(location, dependencies.lock, getCacheLockOptions(0), async () => {
1252
- const latest = await loadCacheLog(location, alintVersion);
1253
- await persistCompactedLog(location, latest, dependencies.write);
1254
- return latest;
1255
- });
1256
- } catch (error) {
1257
- if (isNodeErrorCode(error, "ELOCKED")) return fallback;
1258
- throw error;
1259
- }
1261
+ function prepareProject(root, config) {
1262
+ const resolvedConfig = resolveConfigForProject(root, config, { cwd: root });
1263
+ if (resolvedConfig.ignored) return void 0;
1264
+ const effectiveConfig = resolvedConfig.config;
1265
+ return {
1266
+ agent: effectiveConfig.agent,
1267
+ configHash: stableHash({ settings: effectiveConfig.settings }),
1268
+ root,
1269
+ rules: prepareRules(effectiveConfig),
1270
+ settings: effectiveConfig.settings
1271
+ };
1260
1272
  }
1261
- async function withCacheLock(location, lock, options, operation) {
1262
- let compromised;
1263
- const release = await lock.acquire(location, {
1264
- ...options,
1265
- onCompromised: (error) => {
1266
- compromised = error;
1267
- }
1268
- });
1269
- let operationError;
1270
- try {
1271
- const result = await operation();
1272
- if (compromised) throw compromised;
1273
- return result;
1274
- } catch (error) {
1275
- operationError = error;
1276
- throw error;
1277
- } finally {
1278
- try {
1279
- await release();
1280
- } catch (error) {
1281
- if (operationError === void 0) throw error;
1282
- }
1273
+ function prepareRules(config) {
1274
+ return buildRuleRegistry(config).enabledRules.map((enabledRule, ruleIndex) => ({
1275
+ enabledRule,
1276
+ ruleIndex
1277
+ }));
1278
+ }
1279
+ function recordMissingLanguages(into, rules, registry, path) {
1280
+ for (const { enabledRule } of rules) {
1281
+ const languages = resolveRuleLanguages(enabledRule.rule.languages);
1282
+ if (languages.kind !== "list" || languages.skipMissing) continue;
1283
+ for (const languageId of languages.ids) if (!registry.languages.has(languageId)) recordMissingLanguage(into, languageId, enabledRule.id, path);
1283
1284
  }
1284
1285
  }
1285
1286
  //#endregion
@@ -2363,88 +2364,14 @@ function projectExecution(runtime) {
2363
2364
  //#endregion
2364
2365
  //#region src/core/source/planner.ts
2365
2366
  /**
2366
- * Converts one rich extractor result into detached descriptors and admits its compact jobs.
2367
- *
2368
- * The returned outcome promise captures only cache ownership and hashes. This function does not
2369
- * await rule execution, so its `SourceFile`, parser output, and rich targets become unreachable
2370
- * when planning returns.
2371
- */
2372
- async function planSource(input, options) {
2373
- if (options.signal?.aborted) return { outcomes: Promise.resolve([]) };
2374
- let file;
2375
- try {
2376
- file = await options.src.readFile(input.path);
2377
- } catch (error) {
2378
- reportFilePlanningComplete(input, 0, options);
2379
- return {
2380
- failure: fileFailure(input, "read", failureMessage(error, "Failed to read source file.")),
2381
- outcomes: Promise.resolve([])
2382
- };
2383
- }
2384
- if (options.signal?.aborted) return { outcomes: Promise.resolve([]) };
2385
- let targets;
2386
- try {
2387
- targets = await input.language.extract(file, {
2388
- cwd: options.cwd,
2389
- languageOptions: input.languageOptions,
2390
- src: options.src
2391
- });
2392
- } catch (error) {
2393
- reportFilePlanningComplete(input, 0, options);
2394
- return {
2395
- failure: fileFailure(input, "extract", failureMessage(error, "Failed to extract source targets.")),
2396
- outcomes: Promise.resolve([])
2397
- };
2398
- }
2399
- const { contentHash } = file;
2400
- let cacheOwner;
2401
- let jobs;
2402
- let project;
2403
- try {
2404
- project = options.projectSnapshots === false ? void 0 : createProjectSnapshot(input, file.language, file.path, contentHash, targets);
2405
- cacheOwner = options.cacheStore.beginOwner({
2406
- kind: "file",
2407
- path: file.path
2408
- }, { contentHash });
2409
- const baseFile = {
2410
- contentHash,
2411
- language: file.language,
2412
- path: file.path
2413
- };
2414
- jobs = createSourceJobs(input, createExecutionTargets(input, targets, baseFile, options.ruleRuntimes, options.cwd, cacheOwner));
2415
- } catch (error) {
2416
- reportFilePlanningComplete(input, 0, options);
2417
- return {
2418
- failure: fileFailure(input, "extract", failureMessage(error, "Failed to plan source targets.")),
2419
- outcomes: Promise.resolve([])
2420
- };
2421
- }
2422
- if (options.signal?.aborted) {
2423
- cacheOwner.commit({ mode: "merge" });
2424
- return {
2425
- outcomes: Promise.resolve([]),
2426
- project
2427
- };
2428
- }
2429
- const batch = options.scheduler.schedule(jobs);
2430
- reportFilePlanningComplete(input, batch.jobsAdded, options);
2431
- return {
2432
- outcomes: batch.outcomes.then(async (settled) => {
2433
- cacheOwner.commit(options.signal?.aborted ? { mode: "merge" } : void 0);
2434
- await options.cacheStore.flush();
2435
- return settled;
2436
- }),
2437
- project
2438
- };
2439
- }
2440
- /**
2441
- * Starts every input plan independently and preserves input-ordered results.
2367
+ * Starts every input read independently, then batches CPU-heavy extraction and job admission.
2442
2368
  */
2443
2369
  async function planSources(inputs, options) {
2444
- return Promise.all(inputs.map((input) => planSource(input, {
2370
+ const waitForPlanning = createPlanningAdmission();
2371
+ return Promise.all(inputs.map((input) => planSourceWithAdmission(input, {
2445
2372
  ...options,
2446
2373
  ruleRuntimes: options.createRuleRuntimes(input)
2447
- })));
2374
+ }, waitForPlanning)));
2448
2375
  }
2449
2376
  function createExecutionTargets(input, targets, file, runtimes, cwd, cacheOwner) {
2450
2377
  const resolveIdentity = createTargetIdentityResolver(targets.map((target) => ({
@@ -2499,6 +2426,28 @@ function createExecutionTargets(input, targets, file, runtimes, cwd, cacheOwner)
2499
2426
  };
2500
2427
  });
2501
2428
  }
2429
+ function createPlanningAdmission() {
2430
+ const batchSize = 32;
2431
+ let admitted = 0;
2432
+ let pendingYield;
2433
+ return async () => {
2434
+ let currentPendingYield = pendingYield;
2435
+ while (currentPendingYield) {
2436
+ await currentPendingYield;
2437
+ currentPendingYield = pendingYield;
2438
+ }
2439
+ admitted += 1;
2440
+ if (admitted < batchSize) return;
2441
+ admitted = 0;
2442
+ const currentYield = setImmediate();
2443
+ pendingYield = currentYield;
2444
+ try {
2445
+ await currentYield;
2446
+ } finally {
2447
+ if (pendingYield === currentYield) pendingYield = void 0;
2448
+ }
2449
+ };
2450
+ }
2502
2451
  function createProjectSnapshot(input, language, path, contentHash, targets) {
2503
2452
  return {
2504
2453
  configHash: input.configHash,
@@ -2570,6 +2519,76 @@ function fileFailure(input, kind, message) {
2570
2519
  message
2571
2520
  };
2572
2521
  }
2522
+ async function planSourceWithAdmission(input, options, waitForPlanning) {
2523
+ if (options.signal?.aborted) return { outcomes: Promise.resolve([]) };
2524
+ let file;
2525
+ try {
2526
+ file = await options.src.readFile(input.path);
2527
+ } catch (error) {
2528
+ reportFilePlanningComplete(input, 0, options);
2529
+ return {
2530
+ failure: fileFailure(input, "read", failureMessage(error, "Failed to read source file.")),
2531
+ outcomes: Promise.resolve([])
2532
+ };
2533
+ }
2534
+ if (options.signal?.aborted) return { outcomes: Promise.resolve([]) };
2535
+ await waitForPlanning?.();
2536
+ if (options.signal?.aborted) return { outcomes: Promise.resolve([]) };
2537
+ let targets;
2538
+ try {
2539
+ targets = await input.language.extract(file, {
2540
+ cwd: options.cwd,
2541
+ languageOptions: input.languageOptions,
2542
+ src: options.src
2543
+ });
2544
+ } catch (error) {
2545
+ reportFilePlanningComplete(input, 0, options);
2546
+ return {
2547
+ failure: fileFailure(input, "extract", failureMessage(error, "Failed to extract source targets.")),
2548
+ outcomes: Promise.resolve([])
2549
+ };
2550
+ }
2551
+ const { contentHash } = file;
2552
+ let cacheOwner;
2553
+ let jobs;
2554
+ let project;
2555
+ try {
2556
+ project = options.projectSnapshots === false ? void 0 : createProjectSnapshot(input, file.language, file.path, contentHash, targets);
2557
+ cacheOwner = options.cacheStore.beginOwner({
2558
+ kind: "file",
2559
+ path: file.path
2560
+ }, { contentHash });
2561
+ const baseFile = {
2562
+ contentHash,
2563
+ language: file.language,
2564
+ path: file.path
2565
+ };
2566
+ jobs = createSourceJobs(input, createExecutionTargets(input, targets, baseFile, options.ruleRuntimes, options.cwd, cacheOwner));
2567
+ } catch (error) {
2568
+ reportFilePlanningComplete(input, 0, options);
2569
+ return {
2570
+ failure: fileFailure(input, "extract", failureMessage(error, "Failed to plan source targets.")),
2571
+ outcomes: Promise.resolve([])
2572
+ };
2573
+ }
2574
+ if (options.signal?.aborted) {
2575
+ cacheOwner.commit({ mode: "merge" });
2576
+ return {
2577
+ outcomes: Promise.resolve([]),
2578
+ project
2579
+ };
2580
+ }
2581
+ const batch = options.scheduler.schedule(jobs);
2582
+ reportFilePlanningComplete(input, batch.jobsAdded, options);
2583
+ return {
2584
+ outcomes: batch.outcomes.then(async (settled) => {
2585
+ cacheOwner.commit(options.signal?.aborted ? { mode: "merge" } : void 0);
2586
+ await options.cacheStore.flush();
2587
+ return settled;
2588
+ }),
2589
+ project
2590
+ };
2591
+ }
2573
2592
  function reportFilePlanningComplete(input, jobsAdded, options) {
2574
2593
  try {
2575
2594
  const progress = options.scheduler.completeFilePlanning();
@@ -3120,6 +3139,7 @@ async function measureModel({ model, prompt, signal }) {
3120
3139
  }];
3121
3140
  const startedAt = performance.now();
3122
3141
  let firstOutputAt;
3142
+ let outputDeltaCount = 0;
3123
3143
  const result = streamText({
3124
3144
  ...model.params,
3125
3145
  abortSignal: signal,
@@ -3139,7 +3159,10 @@ async function measureModel({ model, prompt, signal }) {
3139
3159
  ]);
3140
3160
  let settled;
3141
3161
  try {
3142
- for await (const event of result.eventStream) if (firstOutputAt === void 0 && (event.type === "reasoning.delta" || event.type === "text.delta")) firstOutputAt = performance.now();
3162
+ for await (const event of result.eventStream) if ((event.type === "reasoning.delta" || event.type === "text.delta") && event.delta.length > 0) {
3163
+ outputDeltaCount += 1;
3164
+ firstOutputAt ??= performance.now();
3165
+ }
3143
3166
  } finally {
3144
3167
  settled = await completion;
3145
3168
  }
@@ -3148,7 +3171,7 @@ async function measureModel({ model, prompt, signal }) {
3148
3171
  if (usageResult.status === "rejected") throw usageResult.reason;
3149
3172
  return {
3150
3173
  durationMs: endedAt - startedAt,
3151
- firstOutputMs: firstOutputAt === void 0 ? void 0 : firstOutputAt - startedAt,
3174
+ firstOutputMs: firstOutputAt === void 0 || outputDeltaCount < 2 ? void 0 : firstOutputAt - startedAt,
3152
3175
  outputTokens: usageResult.value?.outputTokens
3153
3176
  };
3154
3177
  }
@@ -3245,4 +3268,4 @@ function sampleCorpus(seed) {
3245
3268
  ].map((section) => `--- corpus section ${section} ---\n${rotated}`).join("\n\n");
3246
3269
  }
3247
3270
  //#endregion
3248
- export { AlintAbortError, AlintCachePersistenceError, AlintRunCancelledError, AlintRunError, benchmarkModels, buildRuleRegistry, createBuiltInLanguageRegistry, createSourceExtractor, createSourceFile, createSourceRuntime, defaultStopGateConfig, defineConfig, definePlugin, defineRule, hasDiscoveryFilePatterns, matchesDiscoveryFile, maximumStopGateTimeoutMs, normalizeConfig, registerLanguage, resolveConfigForDirectory, resolveConfigForFile, resolveConfigForProject, resolveLanguage, resolveModel, resolveStopGateConfig, runAlint, sliceLines, sliceRange, stableHash, targetIdentity, withLanguage, withStableIdentities };
3271
+ export { AlintAbortError, AlintCachePersistenceError, AlintRunCancelledError, AlintRunError, benchmarkModels, buildRuleRegistry, createBuiltInLanguageRegistry, createSourceExtractor, createSourceFile, createSourceRuntime, defaultStopGateConfig, defineConfig, definePlugin, defineRule, hasDiscoveryFilePatterns, matchesDiscoveryFile, maximumStopGateTimeoutMs, normalizeConfig, normalizeRunnerCacheConfig, registerLanguage, resolveConfigForDirectory, resolveConfigForFile, resolveConfigForProject, resolveLanguage, resolveModel, resolveStopGateConfig, runAlint, sliceLines, sliceRange, stableHash, targetIdentity, withLanguage, withStableIdentities };