@alint-js/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -4,12 +4,12 @@ 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";
@@ -269,553 +269,185 @@ function mergeStopGateConfig(resolved, item) {
269
269
  };
270
270
  }
271
271
  //#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
- };
272
+ //#region package.json
273
+ var version = "0.7.0";
283
274
  //#endregion
284
- //#region src/core/languages/registry.ts
285
- function createLanguageRegistry$1() {
275
+ //#region src/core/cache/types.ts
276
+ const CACHE_MAGIC = "ALINT_CACHE";
277
+ //#endregion
278
+ //#region src/core/cache/store.ts
279
+ const DEFAULT_CACHE_FILE_NAME = ".alintcache";
280
+ const LOCK_STALE_MS = 2e4;
281
+ const LOCK_UPDATE_MS = 3e3;
282
+ const METADATA_READ_LIMIT = 4096;
283
+ const FiniteNumberSchema = pipe(number(), finite());
284
+ const PositionSchema = object({
285
+ column: FiniteNumberSchema,
286
+ line: FiniteNumberSchema
287
+ });
288
+ const SourceLocationSchema = object({
289
+ end: PositionSchema,
290
+ start: PositionSchema
291
+ });
292
+ const DiagnosticLocationSchema = object({
293
+ end: optional(PositionSchema),
294
+ start: PositionSchema
295
+ });
296
+ const SourceRangeSchema = object({
297
+ end: FiniteNumberSchema,
298
+ start: FiniteNumberSchema
299
+ });
300
+ const DiagnosticSchema = object({
301
+ cached: optional(boolean()),
302
+ evidence: optional(custom(isJsonValue)),
303
+ filePath: string(),
304
+ loc: optional(DiagnosticLocationSchema),
305
+ message: string(),
306
+ model: optional(object({
307
+ providerId: string(),
308
+ requested: optional(string()),
309
+ resolvedId: string()
310
+ })),
311
+ ruleId: string(),
312
+ severity: union([literal("error"), literal("warn")])
313
+ });
314
+ const UsageSchema = object({
315
+ filePath: optional(string()),
316
+ inputTokens: optional(FiniteNumberSchema),
317
+ metadata: optional(custom(isJsonValue)),
318
+ modelId: string(),
319
+ outputTokens: optional(FiniteNumberSchema),
320
+ providerId: string(),
321
+ ruleId: string(),
322
+ totalTokens: optional(FiniteNumberSchema)
323
+ });
324
+ const CacheEntrySchema = object({
325
+ diagnostics: array(DiagnosticSchema),
326
+ fingerprint: object({
327
+ configHash: string(),
328
+ modelHash: string(),
329
+ ruleHash: string(),
330
+ targetHash: string()
331
+ }),
332
+ target: object({
333
+ hash: string(),
334
+ identity: string(),
335
+ kind: string(),
336
+ loc: optional(SourceLocationSchema),
337
+ name: optional(string()),
338
+ range: optional(SourceRangeSchema)
339
+ }),
340
+ usage: array(UsageSchema)
341
+ });
342
+ const CachedOwnerSchema = object({
343
+ contentHash: optional(string()),
344
+ kind: union([literal("file"), literal("project")]),
345
+ path: string(),
346
+ slots: array(string())
347
+ });
348
+ function objectRecord(value) {
349
+ return pipe(unknown(), check((input) => typeof input === "object" && input !== null && !Array.isArray(input)), record(string(), value));
350
+ }
351
+ const CacheFileBodySchema = pipe(object({
352
+ createdAt: string(),
353
+ entries: objectRecord(CacheEntrySchema),
354
+ owners: objectRecord(CachedOwnerSchema),
355
+ updatedAt: string()
356
+ }), check((body) => {
357
+ const entryKeys = Object.keys(body.entries);
358
+ const referencedSlots = Object.values(body.owners).flatMap((owner) => owner.slots);
359
+ const uniqueSlots = new Set(referencedSlots);
360
+ return uniqueSlots.size === referencedSlots.length && uniqueSlots.size === entryKeys.length && entryKeys.every((key) => uniqueSlots.has(key));
361
+ }));
362
+ const CacheMetadataSchema = object({
363
+ alintVersion: string(),
364
+ createdAt: string(),
365
+ magic: literal(CACHE_MAGIC),
366
+ schemaVersion: literal(2),
367
+ type: literal("metadata")
368
+ });
369
+ const PutEventSchema = object({
370
+ at: string(),
371
+ entry: CacheEntrySchema,
372
+ owner: CachedOwnerSchema,
373
+ ownerKey: string(),
374
+ slotKey: string(),
375
+ type: literal("put")
376
+ });
377
+ const ReplaceOwnerEventSchema = object({
378
+ at: string(),
379
+ entries: array(object({
380
+ entry: CacheEntrySchema,
381
+ slotKey: string()
382
+ })),
383
+ mode: union([literal("merge"), literal("replace")]),
384
+ owner: CachedOwnerSchema,
385
+ ownerKey: string(),
386
+ type: literal("replace-owner")
387
+ });
388
+ const RemoveOwnerEventSchema = object({
389
+ at: string(),
390
+ ownerKey: string(),
391
+ type: literal("remove-owner")
392
+ });
393
+ const CacheEventSchema = union([
394
+ PutEventSchema,
395
+ ReplaceOwnerEventSchema,
396
+ RemoveOwnerEventSchema
397
+ ]);
398
+ async function createCacheStore(options) {
399
+ const location = resolveCacheLocation(options.cwd, options.location);
400
+ if (!options.enabled) return createNoopCacheStore(location);
401
+ const alintVersion = options.alintVersion ?? version;
402
+ const initial = await loadCacheLog(location, alintVersion);
403
+ if (options.readOnly) return createReadOnlyCacheStore(location, initial.body, options.cwd);
404
+ const dependencies = {
405
+ append: options.appendFile ?? appendFile,
406
+ lock: options.lock ?? { acquire: lockfile.lock },
407
+ write: options.writeFile ?? writeFile
408
+ };
409
+ const trimmed = await trimOnce(location, alintVersion, initial, dependencies);
410
+ const writer = createEventWriter(location, trimmed.metadata, dependencies);
411
+ const fileExists = options.fileExists ?? defaultFileExists;
286
412
  return {
287
- byExtension: /* @__PURE__ */ new Map(),
288
- languages: /* @__PURE__ */ new Map()
413
+ beginOwner: (owner, metadata) => beginOwner(trimmed.body, owner, options.cwd, writer, metadata),
414
+ flush: () => writer.flush(false),
415
+ location,
416
+ reconcile: async () => {
417
+ const removedOwners = await collectMissingFileOwners(trimmed.body, options.cwd, fileExists);
418
+ for (const ownerKey of removedOwners) writer.enqueue({
419
+ at: (/* @__PURE__ */ new Date()).toISOString(),
420
+ ownerKey,
421
+ type: "remove-owner"
422
+ });
423
+ await writer.flush(true);
424
+ }
289
425
  };
290
426
  }
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}".`);
427
+ function createTargetIdentityResolver(targets) {
428
+ const baseCounts = /* @__PURE__ */ new Map();
429
+ const duplicateCandidateCounts = /* @__PURE__ */ new Map();
430
+ for (const target of targets) {
431
+ const base = createBaseTargetIdentity(target);
432
+ baseCounts.set(base, (baseCounts.get(base) ?? 0) + 1);
297
433
  }
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}.`);
434
+ for (const target of targets) {
435
+ const base = createBaseTargetIdentity(target);
436
+ if ((baseCounts.get(base) ?? 0) <= 1) continue;
437
+ const candidate = target.range ? `${base}:${target.range.start}:${target.range.end}` : base;
438
+ duplicateCandidateCounts.set(candidate, (duplicateCandidateCounts.get(candidate) ?? 0) + 1);
338
439
  }
339
- return language;
440
+ return (target, targetIndex) => {
441
+ const base = createBaseTargetIdentity(target);
442
+ if ((baseCounts.get(base) ?? 0) <= 1) return base;
443
+ const candidate = target.range ? `${base}:${target.range.start}:${target.range.end}` : base;
444
+ if ((duplicateCandidateCounts.get(candidate) ?? 0) <= 1) return candidate;
445
+ return `${candidate}:${targetIndex}`;
446
+ };
340
447
  }
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;
349
- }
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
- }
376
- return {
377
- enabledRules,
378
- rules
379
- };
380
- }
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("; ");
386
- }
387
- function isRuleConfigTuple(entry) {
388
- return Array.isArray(entry);
389
- }
390
- function normalizeRuleConfigEntry(entry) {
391
- if (isRuleConfigTuple(entry)) {
392
- const [severity = "warn", ...options] = entry;
393
- return {
394
- options,
395
- severity
396
- };
397
- }
398
- return {
399
- options: [],
400
- severity: entry ?? "warn"
401
- };
402
- }
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;
409
- }
410
- throw new TypeError(`Invalid options for rule "${id}": ${formatRuleOptionIssues(index, result.issues)}`);
411
- }
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]));
419
- }
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
- });
441
- }
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])
449
- });
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);
505
- }
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
513
- };
514
- return {
515
- ids: languages,
516
- kind: "list",
517
- skipMissing: false
518
- };
519
- }
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
- });
548
- };
549
- }
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
- });
600
- }
601
- return {
602
- directories,
603
- files,
604
- missingLanguages,
605
- project: options.projectTargets === false ? void 0 : prepareProject(cwd$2, config),
606
- unregisteredLanguages
607
- };
608
- }
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;
613
- }
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;
618
- return {
619
- agent: effectiveConfig.agent,
620
- configHash: stableHash({ settings: effectiveConfig.settings }),
621
- root,
622
- rules: prepareRules(effectiveConfig),
623
- settings: effectiveConfig.settings
624
- };
625
- }
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("/") || ".";
448
+ function normalizeCachePath(cwd, filePath) {
449
+ const resolvedPath = isAbsolute$1(filePath) ? resolve$1(filePath) : resolve$1(cwd, filePath);
450
+ return relative$1(cwd, resolvedPath).split(sep).join("/") || ".";
819
451
  }
820
452
  function normalizeRunnerCacheConfig(cache, cwd) {
821
453
  if (cache === false) return {
@@ -1055,231 +687,599 @@ function createReadOnlyCacheStore(location, body, cwd) {
1055
687
  reconcile: async () => {}
1056
688
  };
1057
689
  }
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;
690
+ async function defaultFileExists(path) {
691
+ try {
692
+ await access(path);
693
+ return true;
694
+ } catch (error) {
695
+ if (isMissingFileError(error)) return false;
696
+ throw error;
697
+ }
698
+ }
699
+ function emptyLoadedLog(alintVersion) {
700
+ const metadata = {
701
+ alintVersion,
702
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
703
+ magic: CACHE_MAGIC,
704
+ schemaVersion: 2,
705
+ type: "metadata"
706
+ };
707
+ return {
708
+ body: createEmptyCacheBody(metadata.createdAt),
709
+ metadata
710
+ };
711
+ }
712
+ async function ensureCompatibleMetadata(location, metadata, write) {
713
+ try {
714
+ if (parseMetadataLine(await readMetadataLine(location), metadata.alintVersion)) return;
715
+ } catch (error) {
716
+ if (!isMissingFileError(error)) throw error;
717
+ }
718
+ await write(location, `${JSON.stringify(metadata)}\n`);
719
+ }
720
+ function fingerprintsEqual(left, right) {
721
+ return left.configHash === right.configHash && left.modelHash === right.modelHash && left.ruleHash === right.ruleHash && left.targetHash === right.targetHash;
722
+ }
723
+ function getCacheLockOptions(retries) {
724
+ return {
725
+ realpath: false,
726
+ retries,
727
+ stale: LOCK_STALE_MS,
728
+ update: LOCK_UPDATE_MS
729
+ };
730
+ }
731
+ function hasJsonArrayProperties(input, ancestors) {
732
+ let arrayLength;
733
+ let indexCount = 0;
734
+ for (const key of Reflect.ownKeys(input)) {
735
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
736
+ if (!descriptor || !("value" in descriptor)) return false;
737
+ if (key === "length") {
738
+ if (descriptor.enumerable || typeof descriptor.value !== "number") return false;
739
+ arrayLength = descriptor.value;
740
+ continue;
741
+ }
742
+ const index = typeof key === "string" ? Number(key) : NaN;
743
+ if (typeof key !== "string" || !Number.isInteger(index) || index < 0 || index >= 2 ** 32 - 1 || String(index) !== key || !descriptor.enumerable) return false;
744
+ if (!isJsonValueAt(descriptor.value, ancestors)) return false;
745
+ indexCount += 1;
746
+ }
747
+ return arrayLength !== void 0 && indexCount === arrayLength;
748
+ }
749
+ function hasJsonObjectProperties(input, ancestors) {
750
+ for (const key of Reflect.ownKeys(input)) {
751
+ if (typeof key !== "string") return false;
752
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
753
+ if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) return false;
754
+ if (!isJsonValueAt(descriptor.value, ancestors)) return false;
755
+ }
756
+ return true;
757
+ }
758
+ function isJsonValue(input) {
759
+ return isJsonValueAt(input, /* @__PURE__ */ new WeakSet());
760
+ }
761
+ function isJsonValueAt(input, ancestors) {
762
+ if (input === null || typeof input === "string" || typeof input === "boolean") return true;
763
+ if (typeof input === "number") return Number.isFinite(input);
764
+ if (typeof input !== "object") return false;
765
+ if (ancestors.has(input)) return false;
766
+ ancestors.add(input);
767
+ try {
768
+ if (Array.isArray(input)) {
769
+ if (Object.getPrototypeOf(input) !== Array.prototype) return false;
770
+ return hasJsonArrayProperties(input, ancestors);
771
+ }
772
+ if (Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null) return false;
773
+ return hasJsonObjectProperties(input, ancestors);
774
+ } catch {
775
+ return false;
776
+ } finally {
777
+ ancestors.delete(input);
778
+ }
779
+ }
780
+ function isMissingFileError(error) {
781
+ return isNodeErrorCode(error, "ENOENT") || isNodeErrorCode(error, "ENOTDIR");
782
+ }
783
+ async function loadCacheLog(location, alintVersion) {
784
+ try {
785
+ const metadata = parseMetadataLine(await readMetadataLine(location), alintVersion);
786
+ if (!metadata) return emptyLoadedLog(alintVersion);
787
+ return replayLog(await readFile(location, "utf8"), metadata);
788
+ } catch (error) {
789
+ if (isMissingFileError(error)) return emptyLoadedLog(alintVersion);
790
+ throw error;
791
+ }
792
+ }
793
+ function ownerEntries(body, owner) {
794
+ const entries = /* @__PURE__ */ new Map();
795
+ for (const slot of owner?.slots ?? []) {
796
+ const cacheEntry = body.entries[slot];
797
+ if (cacheEntry) entries.set(slot, cacheEntry);
798
+ }
799
+ return entries;
800
+ }
801
+ function ownerKey(owner, cwd) {
802
+ return stableHash({
803
+ kind: owner.kind,
804
+ path: normalizeCachePath(cwd, owner.path)
805
+ });
806
+ }
807
+ function parseMetadataLine(line, expectedAlintVersion) {
808
+ if (!line) return void 0;
809
+ try {
810
+ const value = JSON.parse(line);
811
+ const metadata = parse(CacheMetadataSchema, value);
812
+ if (expectedAlintVersion !== void 0 && metadata.alintVersion !== expectedAlintVersion) return void 0;
813
+ return metadata;
814
+ } catch {
815
+ return;
816
+ }
817
+ }
818
+ async function persistCompactedLog(location, loaded, write) {
819
+ const metadata = parse(CacheMetadataSchema, loaded.metadata);
820
+ const events = Object.entries(loaded.body.owners).map(([ownerKey, owner]) => ({
821
+ at: loaded.body.updatedAt,
822
+ entries: [...ownerEntries(loaded.body, owner)].map(([slotKey, entry]) => ({
823
+ entry,
824
+ slotKey
825
+ })),
826
+ mode: "replace",
827
+ owner,
828
+ ownerKey,
829
+ type: "replace-owner"
830
+ })).map((event) => parse(CacheEventSchema, event));
831
+ const contents = [`${JSON.stringify(metadata)}\n`, ...events.map((event) => `${JSON.stringify(event)}\n`)].join("");
832
+ const tempPath = join(dirname(location), `.${basename$1(location)}.${process.pid}.${randomUUID()}.tmp`);
833
+ try {
834
+ await write(tempPath, contents);
835
+ await rename(tempPath, location);
836
+ } catch (error) {
837
+ await rm(tempPath, { force: true }).catch(() => {});
838
+ throw error;
839
+ }
840
+ }
841
+ async function readMetadataLine(location) {
842
+ const handle = await open(location, "r");
843
+ try {
844
+ const buffer = Buffer.alloc(METADATA_READ_LIMIT);
845
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
846
+ const newlineIndex = buffer.subarray(0, bytesRead).indexOf(10);
847
+ if (newlineIndex < 0) return void 0;
848
+ const lineEnd = newlineIndex > 0 && buffer[newlineIndex - 1] === 13 ? newlineIndex - 1 : newlineIndex;
849
+ return buffer.toString("utf8", 0, lineEnd);
850
+ } finally {
851
+ await handle.close();
852
+ }
853
+ }
854
+ function removeOwner(body, key) {
855
+ for (const slot of body.owners[key]?.slots ?? []) delete body.entries[slot];
856
+ delete body.owners[key];
857
+ }
858
+ function replayLog(text, metadata) {
859
+ const body = createEmptyCacheBody(metadata.createdAt);
860
+ const lines = text.split(/\r?\n/);
861
+ for (let index = 1; index < lines.length; index += 1) {
862
+ const line = lines[index];
863
+ if (!line) continue;
864
+ try {
865
+ const value = JSON.parse(line);
866
+ applyEvent(body, parse(CacheEventSchema, value));
867
+ } catch {}
868
+ }
869
+ return {
870
+ body: parse(CacheFileBodySchema, body),
871
+ metadata
872
+ };
873
+ }
874
+ function slotKey(owner, slot, cwd) {
875
+ return stableHash({
876
+ owner: ownerKey(owner, cwd),
877
+ ...slot
878
+ });
879
+ }
880
+ async function trimOnce(location, alintVersion, fallback, dependencies) {
881
+ await mkdir(dirname(location), { recursive: true });
882
+ try {
883
+ return await withCacheLock(location, dependencies.lock, getCacheLockOptions(0), async () => {
884
+ const latest = await loadCacheLog(location, alintVersion);
885
+ await persistCompactedLog(location, latest, dependencies.write);
886
+ return latest;
887
+ });
888
+ } catch (error) {
889
+ if (isNodeErrorCode(error, "ELOCKED")) return fallback;
890
+ throw error;
891
+ }
892
+ }
893
+ async function withCacheLock(location, lock, options, operation) {
894
+ let compromised;
895
+ const release = await lock.acquire(location, {
896
+ ...options,
897
+ onCompromised: (error) => {
898
+ compromised = error;
899
+ }
900
+ });
901
+ let operationError;
902
+ try {
903
+ const result = await operation();
904
+ if (compromised) throw compromised;
905
+ return result;
906
+ } catch (error) {
907
+ operationError = error;
908
+ throw error;
909
+ } finally {
910
+ try {
911
+ await release();
912
+ } catch (error) {
913
+ if (operationError === void 0) throw error;
914
+ }
915
+ }
916
+ }
917
+ //#endregion
918
+ //#region src/core/languages/js/index.ts
919
+ const javascriptLanguage = {
920
+ extensions: [
921
+ ".cjs",
922
+ ".js",
923
+ ".jsx",
924
+ ".mjs"
925
+ ],
926
+ extract: (file) => extractJsSourceTargets(withLanguage(file, "javascript")),
927
+ name: "javascript"
928
+ };
929
+ //#endregion
930
+ //#region src/core/languages/registry.ts
931
+ function createLanguageRegistry$1() {
932
+ return {
933
+ byExtension: /* @__PURE__ */ new Map(),
934
+ languages: /* @__PURE__ */ new Map()
935
+ };
936
+ }
937
+ function registerLanguage(registry, language) {
938
+ const existing = registry.languages.get(language.name);
939
+ if (existing && existing !== language) throw new Error(`Duplicate language "${language.name}".`);
940
+ for (const extension of language.extensions ?? []) {
941
+ const existingOwner = registry.byExtension.get(extension);
942
+ if (existingOwner && existingOwner !== language.name) throw new Error(`Duplicate language extension "${extension}".`);
1065
943
  }
944
+ registry.languages.set(language.name, language);
945
+ for (const extension of language.extensions ?? []) registry.byExtension.set(extension, language.name);
1066
946
  }
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
- };
947
+ //#endregion
948
+ //#region src/core/languages/text.ts
949
+ const textLanguage = {
950
+ extensions: [],
951
+ extract: (file) => [{
952
+ file,
953
+ identity: "file",
954
+ kind: "file",
955
+ language: "plaintext",
956
+ origin: { physicalPath: file.path },
957
+ text: file.text
958
+ }],
959
+ name: "plaintext"
960
+ };
961
+ //#endregion
962
+ //#region src/core/languages/ts/index.ts
963
+ const typescriptLanguage = {
964
+ extensions: [
965
+ ".cts",
966
+ ".mts",
967
+ ".ts",
968
+ ".tsx"
969
+ ],
970
+ extract: (file) => extractJsSourceTargets(withLanguage(file, "typescript")),
971
+ name: "typescript"
972
+ };
973
+ //#endregion
974
+ //#region src/core/languages/resolve.ts
975
+ function resolveLanguage(file, registry, options) {
976
+ return resolveLanguageForPath(file.path, registry, options);
977
+ }
978
+ function resolveLanguageForPath(filePath, registry, options) {
979
+ const languageName = options.language ?? options.processedLanguage ?? registry.byExtension.get(extname$1(filePath)) ?? "plaintext";
980
+ const language = registry.languages.get(languageName);
981
+ if (!language) {
982
+ const registered = [...registry.languages.keys()].sort().join(", ");
983
+ throw new Error(`Unknown language "${languageName}". Languages come from plugins, and these are registered: ${registered}.`);
984
+ }
985
+ return language;
986
+ }
987
+ //#endregion
988
+ //#region src/core/languages/index.ts
989
+ function createBuiltInLanguageRegistry() {
990
+ const registry = createLanguageRegistry$1();
991
+ registerLanguage(registry, textLanguage);
992
+ registerLanguage(registry, javascriptLanguage);
993
+ registerLanguage(registry, typescriptLanguage);
994
+ return registry;
995
+ }
996
+ //#endregion
997
+ //#region src/dsl/registry.ts
998
+ function buildRuleRegistry(config) {
999
+ const rules = /* @__PURE__ */ new Map();
1000
+ const localIds = /* @__PURE__ */ new Map();
1001
+ const enabledRules = [];
1002
+ for (const [alias, plugin] of Object.entries(config.plugins)) for (const [localId, rule] of Object.entries(plugin.rules ?? {})) {
1003
+ const id = `${alias}/${localId}`;
1004
+ if (rules.has(id)) throw new Error(`Duplicate rule id "${id}".`);
1005
+ rules.set(id, rule);
1006
+ localIds.set(id, localId);
1007
+ }
1008
+ for (const [id, entry] of Object.entries(config.rules)) {
1009
+ const rule = rules.get(id);
1010
+ if (!rule) throw new Error(`Unknown rule "${id}".`);
1011
+ const normalizedEntry = normalizeRuleConfigEntry(entry);
1012
+ const severity = normalizedEntry.severity;
1013
+ if (severity === "off") continue;
1014
+ enabledRules.push({
1015
+ id,
1016
+ localId: localIds.get(id) ?? id,
1017
+ options: parseRuleOptions(id, rule.options, normalizedEntry.options),
1018
+ rule,
1019
+ severity
1020
+ });
1021
+ }
1075
1022
  return {
1076
- body: createEmptyCacheBody(metadata.createdAt),
1077
- metadata
1023
+ enabledRules,
1024
+ rules
1078
1025
  };
1079
1026
  }
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`);
1027
+ function formatRuleOptionIssues(index, issues) {
1028
+ return issues.map((issue) => {
1029
+ const path = getDotPath(issue);
1030
+ return path === null ? `"${index}": ${issue.message}` : `"${index}.${path}": ${issue.message}`;
1031
+ }).join("; ");
1087
1032
  }
1088
- function fingerprintsEqual(left, right) {
1089
- return left.configHash === right.configHash && left.modelHash === right.modelHash && left.ruleHash === right.ruleHash && left.targetHash === right.targetHash;
1033
+ function isRuleConfigTuple(entry) {
1034
+ return Array.isArray(entry);
1090
1035
  }
1091
- function getCacheLockOptions(retries) {
1036
+ function normalizeRuleConfigEntry(entry) {
1037
+ if (isRuleConfigTuple(entry)) {
1038
+ const [severity = "warn", ...options] = entry;
1039
+ return {
1040
+ options,
1041
+ severity
1042
+ };
1043
+ }
1092
1044
  return {
1093
- realpath: false,
1094
- retries,
1095
- stale: LOCK_STALE_MS,
1096
- update: LOCK_UPDATE_MS
1045
+ options: [],
1046
+ severity: entry ?? "warn"
1097
1047
  };
1098
1048
  }
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;
1049
+ function parseRuleOption(id, index, schema, value) {
1050
+ const result = safeParse(schema, value);
1051
+ if (result.success) return result.output;
1052
+ if (value === void 0) {
1053
+ const emptyObjectResult = safeParse(schema, {});
1054
+ if (emptyObjectResult.success) return emptyObjectResult.output;
1114
1055
  }
1115
- return arrayLength !== void 0 && indexCount === arrayLength;
1056
+ throw new TypeError(`Invalid options for rule "${id}": ${formatRuleOptionIssues(index, result.issues)}`);
1116
1057
  }
1117
- function hasJsonObjectProperties(input, ancestors) {
1118
- for (const key of Reflect.ownKeys(input)) {
1119
- if (typeof key !== "string") return false;
1120
- const descriptor = Object.getOwnPropertyDescriptor(input, key);
1121
- if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) return false;
1122
- if (!isJsonValueAt(descriptor.value, ancestors)) return false;
1058
+ function parseRuleOptions(id, schema, options) {
1059
+ if (!schema) {
1060
+ if (options.length > 0) throw new TypeError(`Rule "${id}" does not accept options.`);
1061
+ return [];
1123
1062
  }
1124
- return true;
1063
+ if (options.length > schema.length) throw new TypeError(`Invalid options for rule "${id}": Unexpected option at index ${schema.length}.`);
1064
+ return schema.map((optionSchema, index) => parseRuleOption(id, index, optionSchema, options[index]));
1125
1065
  }
1126
- function isJsonValue(input) {
1127
- return isJsonValueAt(input, /* @__PURE__ */ new WeakSet());
1066
+ //#endregion
1067
+ //#region src/core/languages/diagnostics.ts
1068
+ /** A rule named a language and nothing registered it. This is a builtin rule. Not a real rule. */
1069
+ const MISSING_LANGUAGE_RULE = "alint/missing-language";
1070
+ /** A linted file's extension belongs to no registered language. This is a builtin rule. Not a real rule either. */
1071
+ const UNREGISTERED_LANGUAGE_RULE = "alint/unregistered-language";
1072
+ /**
1073
+ * One diagnostic per missing language, always an error: a rule scoped to a language nothing provides
1074
+ * matches no file, so leaving it at a warning would let a run pass having silently checked nothing.
1075
+ */
1076
+ function missingLanguageDiagnostics(missing) {
1077
+ return [...missing.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([languageId, entry]) => {
1078
+ const ruleIds = [...entry.ruleIds].sort();
1079
+ const subject = ruleIds.length === 1 ? `Rule "${ruleIds[0]}" needs` : `Rules ${ruleIds.map((id) => `"${id}"`).join(", ")} need`;
1080
+ return {
1081
+ filePath: entry.filePath,
1082
+ message: `${subject} the "${languageId}" language, which no plugin registered. Add a plugin that provides "${languageId}", or turn the rule off.`,
1083
+ ruleId: MISSING_LANGUAGE_RULE,
1084
+ severity: "error"
1085
+ };
1086
+ });
1128
1087
  }
1129
- function isJsonValueAt(input, ancestors) {
1130
- if (input === null || typeof input === "string" || typeof input === "boolean") return true;
1131
- if (typeof input === "number") return Number.isFinite(input);
1132
- if (typeof input !== "object") return false;
1133
- if (ancestors.has(input)) return false;
1134
- ancestors.add(input);
1135
- try {
1136
- if (Array.isArray(input)) {
1137
- if (Object.getPrototypeOf(input) !== Array.prototype) return false;
1138
- return hasJsonArrayProperties(input, ancestors);
1139
- }
1140
- if (Object.getPrototypeOf(input) !== Object.prototype && Object.getPrototypeOf(input) !== null) return false;
1141
- return hasJsonObjectProperties(input, ancestors);
1142
- } catch {
1143
- return false;
1144
- } finally {
1145
- ancestors.delete(input);
1088
+ /** Records one rule that named a language the config never registered, grouped by language id. */
1089
+ function recordMissingLanguage(into, languageId, ruleId, path) {
1090
+ const existing = into.get(languageId);
1091
+ if (existing === void 0) {
1092
+ into.set(languageId, {
1093
+ filePath: path,
1094
+ ruleIds: /* @__PURE__ */ new Set([ruleId])
1095
+ });
1096
+ return;
1146
1097
  }
1098
+ existing.ruleIds.add(ruleId);
1099
+ if (path < existing.filePath) existing.filePath = path;
1147
1100
  }
1148
- function isMissingFileError(error) {
1149
- return isNodeErrorCode(error, "ENOENT") || isNodeErrorCode(error, "ENOTDIR");
1101
+ /**
1102
+ * Records one linted file no language claimed, grouped by extension so a missing pack is one
1103
+ * diagnostic, not one per file. `'off'` records nothing.
1104
+ */
1105
+ function recordUnregistered(into, path, severity) {
1106
+ if (severity === "off") return;
1107
+ const key = extname(path) || basename(path);
1108
+ const existing = into.get(key);
1109
+ into.set(key, {
1110
+ count: (existing?.count ?? 0) + 1,
1111
+ filePath: existing !== void 0 && existing.filePath < path ? existing.filePath : path,
1112
+ severity: existing?.severity === "error" ? "error" : severity
1113
+ });
1150
1114
  }
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;
1159
- }
1115
+ /** One diagnostic per extension, sorted, for the run to prepend to its results. */
1116
+ function unregisteredLanguageDiagnostics(unregistered) {
1117
+ return [...unregistered.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([extension, entry]) => ({
1118
+ filePath: entry.filePath,
1119
+ 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.`,
1120
+ ruleId: UNREGISTERED_LANGUAGE_RULE,
1121
+ severity: entry.severity
1122
+ }));
1123
+ }
1124
+ function unregisteredLanguageSeverity(linterOptions) {
1125
+ const value = linterOptions.reportUnregisteredLanguages;
1126
+ return value === "error" || value === "off" ? value : "warn";
1160
1127
  }
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);
1166
- }
1167
- return entries;
1128
+ //#endregion
1129
+ //#region src/core/languages/rule-languages.ts
1130
+ /**
1131
+ * The language a file falls back to when no registered language claims its extension. Not a real
1132
+ * language, which is why `'any'` excludes it.
1133
+ */
1134
+ const PLAIN_TEXT = "plaintext";
1135
+ /**
1136
+ * Whether a rule reads this target.
1137
+ *
1138
+ * A rule with language off still reads file targets, because reading raw text, or asking a model about a
1139
+ * whole file, needs no language. However, it never reads the function and class targets extraction produces.
1140
+ * `'file'` is the one kind every language yields, plain text included, so it is the dividing line.
1141
+ *
1142
+ * `'any'` means any real language, so a file that fell back to plain text because nothing claimed
1143
+ * its extension is withheld: the rule asked for structure and there is none to give. The run says so
1144
+ * once per extension through `alint/unregistered-language` rather than letting each rule report an
1145
+ * empty file.
1146
+ */
1147
+ function isTargetLanguageAccepted(languages, targetKind, targetLanguage) {
1148
+ if (languages.kind === "off") return targetKind === "file";
1149
+ if (languages.kind === "any") return targetLanguage !== PLAIN_TEXT;
1150
+ return languages.ids.includes(targetLanguage);
1168
1151
  }
1169
- function ownerKey(owner, cwd) {
1170
- return stableHash({
1171
- kind: owner.kind,
1172
- path: normalizeCachePath(cwd, owner.path)
1173
- });
1152
+ function resolveRuleLanguages(languages) {
1153
+ if (languages === void 0) return { kind: "off" };
1154
+ if (languages === "any") return { kind: "any" };
1155
+ if ("ids" in languages) return {
1156
+ ids: languages.ids,
1157
+ kind: "list",
1158
+ skipMissing: languages.skipMissing ?? false
1159
+ };
1160
+ return {
1161
+ ids: languages,
1162
+ kind: "list",
1163
+ skipMissing: false
1164
+ };
1174
1165
  }
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 {
1183
- return;
1184
- }
1166
+ //#endregion
1167
+ //#region src/core/preparation.ts
1168
+ /**
1169
+ * The `ctx.src.extract` a run hands to rules, for parsing files it was never asked to lint, which
1170
+ * an index builder sweeping the workspace needs.
1171
+ *
1172
+ * It resolves each file's OWN config rather than a caller's, because the language a file resolves to
1173
+ * is a config decision and two config groups may register different plugins. It extracts on demand
1174
+ * and holds nothing: source planning releases rich extractor values after compact jobs are admitted,
1175
+ * and a run-wide parse memo here would put that retention back. A caller keeps only what it derives.
1176
+ *
1177
+ * `getSrc` defers reading the runtime because the runtime is built around this closure. It is assigned
1178
+ * before any rule runs, so by the first call it is present.
1179
+ */
1180
+ function createSourceExtractor(cwd, config, getSrc) {
1181
+ return async (filePath, options = {}) => {
1182
+ const path = resolve(cwd, filePath);
1183
+ const resolvedConfig = resolveConfigForFile(path, config, { cwd });
1184
+ if (resolvedConfig.ignored) return [];
1185
+ const effectiveConfig = resolvedConfig.config;
1186
+ const language = resolveLanguageForPath(path, createLanguageRegistry(effectiveConfig), { language: options.language ?? effectiveConfig.language });
1187
+ const src = getSrc();
1188
+ const file = await src.readFile(path);
1189
+ return language.extract(file, {
1190
+ cwd,
1191
+ languageOptions: effectiveConfig.languageOptions,
1192
+ src
1193
+ });
1194
+ };
1185
1195
  }
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;
1196
+ function prepareRun(options = {}) {
1197
+ const cwd$2 = options.cwd ?? cwd();
1198
+ const config = options.config ?? [];
1199
+ const files = [];
1200
+ const directories = [];
1201
+ const missingLanguages = /* @__PURE__ */ new Map();
1202
+ const unregisteredLanguages = /* @__PURE__ */ new Map();
1203
+ for (const filePath of options.files ?? []) {
1204
+ const path = resolve(cwd$2, filePath);
1205
+ const resolvedConfig = resolveConfigForFile(path, config, { cwd: cwd$2 });
1206
+ if (resolvedConfig.ignored) continue;
1207
+ const effectiveConfig = resolvedConfig.config;
1208
+ const languageRegistry = createLanguageRegistry(effectiveConfig);
1209
+ const language = resolveLanguageForPath(path, languageRegistry, { language: effectiveConfig.language });
1210
+ const rules = prepareRules(effectiveConfig);
1211
+ recordMissingLanguages(missingLanguages, rules, languageRegistry, path);
1212
+ if (effectiveConfig.language === void 0 && language.name === "plaintext" && rules.some(({ enabledRule }) => !isTargetLanguageAccepted(resolveRuleLanguages(enabledRule.rule.languages), "file", "plaintext"))) recordUnregistered(unregisteredLanguages, path, unregisteredLanguageSeverity(effectiveConfig.linterOptions));
1213
+ files.push({
1214
+ agent: effectiveConfig.agent,
1215
+ configHash: stableHash({
1216
+ language: effectiveConfig.language,
1217
+ languageOptions: effectiveConfig.languageOptions,
1218
+ processor: effectiveConfig.processor,
1219
+ resolvedLanguage: language.name,
1220
+ settings: effectiveConfig.settings
1221
+ }),
1222
+ fileIndex: files.length,
1223
+ language,
1224
+ languageOptions: effectiveConfig.languageOptions,
1225
+ path,
1226
+ rules,
1227
+ settings: effectiveConfig.settings
1228
+ });
1207
1229
  }
1208
- }
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();
1230
+ for (const directoryPath of options.directories ?? []) {
1231
+ const path = resolve(cwd$2, directoryPath);
1232
+ const resolvedConfig = resolveConfigForDirectory(path, config, { cwd: cwd$2 });
1233
+ if (resolvedConfig.ignored) continue;
1234
+ const effectiveConfig = resolvedConfig.config;
1235
+ directories.push({
1236
+ agent: effectiveConfig.agent,
1237
+ configHash: stableHash({ settings: effectiveConfig.settings }),
1238
+ directoryIndex: directories.length,
1239
+ rules: prepareRules(effectiveConfig),
1240
+ settings: effectiveConfig.settings,
1241
+ target: {
1242
+ kind: "directory",
1243
+ path
1244
+ }
1245
+ });
1220
1246
  }
1247
+ return {
1248
+ directories,
1249
+ files,
1250
+ missingLanguages,
1251
+ project: options.projectTargets === false ? void 0 : prepareProject(cwd$2, config),
1252
+ unregisteredLanguages
1253
+ };
1221
1254
  }
1222
- function removeOwner(body, key) {
1223
- for (const slot of body.owners[key]?.slots ?? []) delete body.entries[slot];
1224
- delete body.owners[key];
1255
+ function createLanguageRegistry(config) {
1256
+ const registry = createBuiltInLanguageRegistry();
1257
+ for (const plugin of Object.values(config.plugins)) for (const language of Object.values(plugin.languages ?? {})) registerLanguage(registry, language);
1258
+ return registry;
1225
1259
  }
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 {}
1236
- }
1260
+ function prepareProject(root, config) {
1261
+ const resolvedConfig = resolveConfigForProject(root, config, { cwd: root });
1262
+ if (resolvedConfig.ignored) return void 0;
1263
+ const effectiveConfig = resolvedConfig.config;
1237
1264
  return {
1238
- body: parse(CacheFileBodySchema, body),
1239
- metadata
1265
+ agent: effectiveConfig.agent,
1266
+ configHash: stableHash({ settings: effectiveConfig.settings }),
1267
+ root,
1268
+ rules: prepareRules(effectiveConfig),
1269
+ settings: effectiveConfig.settings
1240
1270
  };
1241
1271
  }
1242
- function slotKey(owner, slot, cwd) {
1243
- return stableHash({
1244
- owner: ownerKey(owner, cwd),
1245
- ...slot
1246
- });
1247
- }
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
- }
1272
+ function prepareRules(config) {
1273
+ return buildRuleRegistry(config).enabledRules.map((enabledRule, ruleIndex) => ({
1274
+ enabledRule,
1275
+ ruleIndex
1276
+ }));
1260
1277
  }
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
- }
1278
+ function recordMissingLanguages(into, rules, registry, path) {
1279
+ for (const { enabledRule } of rules) {
1280
+ const languages = resolveRuleLanguages(enabledRule.rule.languages);
1281
+ if (languages.kind !== "list" || languages.skipMissing) continue;
1282
+ for (const languageId of languages.ids) if (!registry.languages.has(languageId)) recordMissingLanguage(into, languageId, enabledRule.id, path);
1283
1283
  }
1284
1284
  }
1285
1285
  //#endregion
@@ -3120,6 +3120,7 @@ async function measureModel({ model, prompt, signal }) {
3120
3120
  }];
3121
3121
  const startedAt = performance.now();
3122
3122
  let firstOutputAt;
3123
+ let outputDeltaCount = 0;
3123
3124
  const result = streamText({
3124
3125
  ...model.params,
3125
3126
  abortSignal: signal,
@@ -3139,7 +3140,10 @@ async function measureModel({ model, prompt, signal }) {
3139
3140
  ]);
3140
3141
  let settled;
3141
3142
  try {
3142
- for await (const event of result.eventStream) if (firstOutputAt === void 0 && (event.type === "reasoning.delta" || event.type === "text.delta")) firstOutputAt = performance.now();
3143
+ for await (const event of result.eventStream) if ((event.type === "reasoning.delta" || event.type === "text.delta") && event.delta.length > 0) {
3144
+ outputDeltaCount += 1;
3145
+ firstOutputAt ??= performance.now();
3146
+ }
3143
3147
  } finally {
3144
3148
  settled = await completion;
3145
3149
  }
@@ -3148,7 +3152,7 @@ async function measureModel({ model, prompt, signal }) {
3148
3152
  if (usageResult.status === "rejected") throw usageResult.reason;
3149
3153
  return {
3150
3154
  durationMs: endedAt - startedAt,
3151
- firstOutputMs: firstOutputAt === void 0 ? void 0 : firstOutputAt - startedAt,
3155
+ firstOutputMs: firstOutputAt === void 0 || outputDeltaCount < 2 ? void 0 : firstOutputAt - startedAt,
3152
3156
  outputTokens: usageResult.value?.outputTokens
3153
3157
  };
3154
3158
  }
@@ -3245,4 +3249,4 @@ function sampleCorpus(seed) {
3245
3249
  ].map((section) => `--- corpus section ${section} ---\n${rotated}`).join("\n\n");
3246
3250
  }
3247
3251
  //#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 };
3252
+ 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 };