@massu/core 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +9431 -5167
- package/dist/hooks/auto-learning-pipeline.js +18 -0
- package/dist/hooks/classify-failure.js +18 -0
- package/dist/hooks/cost-tracker.js +18 -0
- package/dist/hooks/fix-detector.js +18 -0
- package/dist/hooks/incident-pipeline.js +18 -0
- package/dist/hooks/post-edit-context.js +18 -0
- package/dist/hooks/post-tool-use.js +18 -0
- package/dist/hooks/pre-compact.js +18 -0
- package/dist/hooks/pre-delete-check.js +18 -0
- package/dist/hooks/quality-event.js +18 -0
- package/dist/hooks/rule-enforcement-pipeline.js +18 -0
- package/dist/hooks/session-end.js +18 -0
- package/dist/hooks/session-start.js +2952 -2740
- package/dist/hooks/user-prompt.js +18 -0
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +7 -3
- package/src/adapter.ts +90 -0
- package/src/cli.ts +7 -0
- package/src/commands/adapters.ts +824 -0
- package/src/commands/config-check-drift.ts +1 -0
- package/src/commands/config-refresh.ts +1 -0
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +151 -2
- package/src/config.ts +63 -0
- package/src/detect/adapters/aspnet.ts +293 -0
- package/src/detect/adapters/discover.ts +469 -0
- package/src/detect/adapters/go-chi.ts +261 -0
- package/src/detect/adapters/index.ts +49 -0
- package/src/detect/adapters/phoenix.ts +277 -0
- package/src/detect/adapters/python-flask.ts +235 -0
- package/src/detect/adapters/rails.ts +279 -0
- package/src/detect/adapters/runner.ts +32 -0
- package/src/detect/adapters/spring.ts +284 -0
- package/src/detect/adapters/tree-sitter-loader.ts +50 -0
- package/src/detect/adapters/types.ts +18 -0
- package/src/detect/framework-detector.ts +26 -0
- package/src/detect/manifest-registry.ts +261 -0
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/detect/package-detector.ts +162 -62
- package/src/detect/source-dir-detector.ts +7 -0
- package/src/hooks/post-tool-use.ts +1 -0
- package/src/hooks/session-start.ts +1 -0
- package/src/lib/fileLock.ts +203 -0
- package/src/lib/installLock.ts +31 -144
- package/src/memory-file-ingest.ts +1 -0
- package/src/security/adapter-origin.ts +130 -0
- package/src/security/adapter-verifier.ts +319 -0
- package/src/security/atomic-write.ts +164 -0
- package/src/security/fetcher.ts +200 -0
- package/src/security/install-tracking.ts +319 -0
- package/src/security/local-fingerprint.ts +225 -0
- package/src/security/manifest-cache.ts +333 -0
- package/src/security/manifest-schema.ts +129 -0
- package/src/security/registry-pubkey.generated.ts +35 -0
- package/src/security/telemetry.ts +320 -0
- package/templates/aspnet/massu.config.yaml +61 -0
- package/templates/go-chi/massu.config.yaml +52 -0
- package/templates/phoenix/massu.config.yaml +54 -0
- package/templates/python-flask/massu.config.yaml +51 -0
- package/templates/rails/massu.config.yaml +56 -0
- package/templates/spring/massu.config.yaml +56 -0
|
@@ -47,7 +47,9 @@ export type SupportedLanguage =
|
|
|
47
47
|
| 'swift'
|
|
48
48
|
| 'go'
|
|
49
49
|
| 'java'
|
|
50
|
-
| 'ruby'
|
|
50
|
+
| 'ruby'
|
|
51
|
+
| 'elixir'
|
|
52
|
+
| 'csharp';
|
|
51
53
|
|
|
52
54
|
export interface PackageManifest {
|
|
53
55
|
/** Absolute path to the manifest file. */
|
|
@@ -81,7 +83,9 @@ export interface PackageManifest {
|
|
|
81
83
|
| 'go.mod'
|
|
82
84
|
| 'pom.xml'
|
|
83
85
|
| 'build.gradle'
|
|
84
|
-
| 'Gemfile'
|
|
86
|
+
| 'Gemfile'
|
|
87
|
+
| 'mix.exs'
|
|
88
|
+
| '*.csproj';
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
export interface DetectionWarning {
|
|
@@ -118,19 +122,10 @@ const IGNORED_DIRS = new Set([
|
|
|
118
122
|
'Pods',
|
|
119
123
|
]);
|
|
120
124
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
'Pipfile',
|
|
126
|
-
'Cargo.toml',
|
|
127
|
-
'Package.swift',
|
|
128
|
-
'go.mod',
|
|
129
|
-
'pom.xml',
|
|
130
|
-
'build.gradle',
|
|
131
|
-
'build.gradle.kts',
|
|
132
|
-
'Gemfile',
|
|
133
|
-
];
|
|
125
|
+
// MANIFEST_FILES removed Plan 1.5.1. The canonical list lives at
|
|
126
|
+
// `manifest-registry.ts:getManifestPatterns()`. `detectManifestsInDir`
|
|
127
|
+
// (below) iterates the registry directly so adding a new manifest type
|
|
128
|
+
// requires only a single registry entry — no second list to keep in sync.
|
|
134
129
|
|
|
135
130
|
function safeRead(path: string): string | null {
|
|
136
131
|
try {
|
|
@@ -153,7 +148,7 @@ function normalizeRelative(root: string, path: string): string {
|
|
|
153
148
|
return rel.split(/[/\\]/).join('/');
|
|
154
149
|
}
|
|
155
150
|
|
|
156
|
-
function parsePackageJson(
|
|
151
|
+
export function parsePackageJson(
|
|
157
152
|
path: string,
|
|
158
153
|
directory: string,
|
|
159
154
|
root: string,
|
|
@@ -204,7 +199,7 @@ function parsePackageJson(
|
|
|
204
199
|
};
|
|
205
200
|
}
|
|
206
201
|
|
|
207
|
-
function parsePyproject(
|
|
202
|
+
export function parsePyproject(
|
|
208
203
|
path: string,
|
|
209
204
|
directory: string,
|
|
210
205
|
root: string,
|
|
@@ -323,7 +318,7 @@ function normalizePyDep(spec: string): string {
|
|
|
323
318
|
return name.trim();
|
|
324
319
|
}
|
|
325
320
|
|
|
326
|
-
function parseRequirementsTxt(
|
|
321
|
+
export function parseRequirementsTxt(
|
|
327
322
|
path: string,
|
|
328
323
|
directory: string,
|
|
329
324
|
root: string,
|
|
@@ -355,7 +350,7 @@ function parseRequirementsTxt(
|
|
|
355
350
|
};
|
|
356
351
|
}
|
|
357
352
|
|
|
358
|
-
function parsePipfile(
|
|
353
|
+
export function parsePipfile(
|
|
359
354
|
path: string,
|
|
360
355
|
directory: string,
|
|
361
356
|
root: string,
|
|
@@ -392,7 +387,7 @@ function parsePipfile(
|
|
|
392
387
|
};
|
|
393
388
|
}
|
|
394
389
|
|
|
395
|
-
function parseCargoToml(
|
|
390
|
+
export function parseCargoToml(
|
|
396
391
|
path: string,
|
|
397
392
|
directory: string,
|
|
398
393
|
root: string,
|
|
@@ -430,7 +425,7 @@ function parseCargoToml(
|
|
|
430
425
|
};
|
|
431
426
|
}
|
|
432
427
|
|
|
433
|
-
function parsePackageSwift(
|
|
428
|
+
export function parsePackageSwift(
|
|
434
429
|
path: string,
|
|
435
430
|
directory: string,
|
|
436
431
|
root: string,
|
|
@@ -472,7 +467,7 @@ function parsePackageSwift(
|
|
|
472
467
|
};
|
|
473
468
|
}
|
|
474
469
|
|
|
475
|
-
function parseGoMod(
|
|
470
|
+
export function parseGoMod(
|
|
476
471
|
path: string,
|
|
477
472
|
directory: string,
|
|
478
473
|
root: string,
|
|
@@ -523,7 +518,7 @@ function parseGoMod(
|
|
|
523
518
|
};
|
|
524
519
|
}
|
|
525
520
|
|
|
526
|
-
function parsePomXml(
|
|
521
|
+
export function parsePomXml(
|
|
527
522
|
path: string,
|
|
528
523
|
directory: string,
|
|
529
524
|
root: string,
|
|
@@ -554,7 +549,7 @@ function parsePomXml(
|
|
|
554
549
|
};
|
|
555
550
|
}
|
|
556
551
|
|
|
557
|
-
function parseBuildGradle(
|
|
552
|
+
export function parseBuildGradle(
|
|
558
553
|
path: string,
|
|
559
554
|
directory: string,
|
|
560
555
|
root: string,
|
|
@@ -591,7 +586,7 @@ function parseBuildGradle(
|
|
|
591
586
|
};
|
|
592
587
|
}
|
|
593
588
|
|
|
594
|
-
function parseGemfile(
|
|
589
|
+
export function parseGemfile(
|
|
595
590
|
path: string,
|
|
596
591
|
directory: string,
|
|
597
592
|
root: string,
|
|
@@ -628,54 +623,159 @@ function parseGemfile(
|
|
|
628
623
|
};
|
|
629
624
|
}
|
|
630
625
|
|
|
626
|
+
/**
|
|
627
|
+
* Parse a `mix.exs` (Elixir / Mix) file. Plan 1.5.1 — closes CR-39
|
|
628
|
+
* gap where Phoenix projects failed `npx massu init` with "no languages
|
|
629
|
+
* detected" because the package-detector layer didn't recognize the
|
|
630
|
+
* manifest. The AST adapter at `detect/adapters/phoenix.ts` was already
|
|
631
|
+
* shipped in 1.5.0 and works correctly when given a SourceFile[] directly.
|
|
632
|
+
*
|
|
633
|
+
* mix.exs is Elixir source (not a declarative format), but for detection
|
|
634
|
+
* purposes we extract `{:dep, "~> X.Y"}` style declarations via a regex
|
|
635
|
+
* scan. False-positive risk is low (any `{:atom, ...}` pattern that's
|
|
636
|
+
* NOT a dep is rare in mix.exs files outside the deps function).
|
|
637
|
+
*/
|
|
638
|
+
export function parseMixExs(
|
|
639
|
+
path: string,
|
|
640
|
+
directory: string,
|
|
641
|
+
root: string,
|
|
642
|
+
_warnings: DetectionWarning[],
|
|
643
|
+
): PackageManifest | null {
|
|
644
|
+
const raw = safeRead(path);
|
|
645
|
+
if (raw === null) return null;
|
|
646
|
+
const deps: string[] = [];
|
|
647
|
+
// Match `{:dep_name, ...}` — atom name as first tuple element.
|
|
648
|
+
const depPattern = /\{\s*:([a-z][a-z0-9_]*)\s*,/g;
|
|
649
|
+
let m: RegExpExecArray | null;
|
|
650
|
+
while ((m = depPattern.exec(raw)) !== null) {
|
|
651
|
+
if (!deps.includes(m[1])) deps.push(m[1]);
|
|
652
|
+
}
|
|
653
|
+
// Best-effort `app: :name` extraction (the `def project` block usually
|
|
654
|
+
// declares this).
|
|
655
|
+
const appMatch = /\bapp\s*:\s*:([a-z][a-z0-9_]*)/.exec(raw);
|
|
656
|
+
const name = appMatch ? appMatch[1] : null;
|
|
657
|
+
return {
|
|
658
|
+
path,
|
|
659
|
+
relativePath: normalizeRelative(root, path),
|
|
660
|
+
directory,
|
|
661
|
+
language: 'elixir',
|
|
662
|
+
runtime: 'beam',
|
|
663
|
+
name,
|
|
664
|
+
version: null,
|
|
665
|
+
dependencies: deps,
|
|
666
|
+
devDependencies: [],
|
|
667
|
+
scripts: [],
|
|
668
|
+
manifestType: 'mix.exs',
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Parse a `*.csproj` (C# / .NET project) file. Plan 1.5.1 — closes CR-39
|
|
674
|
+
* gap where ASP.NET projects failed `npx massu init`. The AST adapter at
|
|
675
|
+
* `detect/adapters/aspnet.ts` was already shipped in 1.5.0 and works
|
|
676
|
+
* correctly.
|
|
677
|
+
*
|
|
678
|
+
* .csproj is XML; we use a lightweight regex scan rather than pulling
|
|
679
|
+
* an XML parser because the only fields we need are `<PackageReference
|
|
680
|
+
* Include="X" />` (deps) and the `Sdk="..."` attribute (framework hint).
|
|
681
|
+
* Full XML parsing would be over-engineered for this surface and risks
|
|
682
|
+
* adding a dependency for marginal gain.
|
|
683
|
+
*/
|
|
684
|
+
export function parseCsproj(
|
|
685
|
+
path: string,
|
|
686
|
+
directory: string,
|
|
687
|
+
root: string,
|
|
688
|
+
_warnings: DetectionWarning[],
|
|
689
|
+
): PackageManifest | null {
|
|
690
|
+
const raw = safeRead(path);
|
|
691
|
+
if (raw === null) return null;
|
|
692
|
+
const deps: string[] = [];
|
|
693
|
+
// Match `<PackageReference Include="Foo.Bar" ... />` (Include attribute
|
|
694
|
+
// value is the package id; the Version attribute is captured but we
|
|
695
|
+
// discard it since runtime/build distinction isn't expressed via
|
|
696
|
+
// this schema).
|
|
697
|
+
const pkgRefPattern = /<PackageReference\s+[^>]*Include\s*=\s*"([^"]+)"/gi;
|
|
698
|
+
let m: RegExpExecArray | null;
|
|
699
|
+
while ((m = pkgRefPattern.exec(raw)) !== null) {
|
|
700
|
+
if (!deps.includes(m[1])) deps.push(m[1]);
|
|
701
|
+
}
|
|
702
|
+
// The `<Project Sdk="Microsoft.NET.Sdk.Web">` attribute is a strong
|
|
703
|
+
// ASP.NET Core indicator that doesn't appear as a PackageReference.
|
|
704
|
+
// Surface it as a dep so framework-detector rules (which match against
|
|
705
|
+
// the deps set) can fire on it. Same downstream consumer; no new
|
|
706
|
+
// signal channel needed.
|
|
707
|
+
const sdkMatch = /<Project\s+[^>]*Sdk\s*=\s*"([^"]+)"/i.exec(raw);
|
|
708
|
+
if (sdkMatch && !deps.includes(sdkMatch[1])) {
|
|
709
|
+
deps.push(sdkMatch[1]);
|
|
710
|
+
}
|
|
711
|
+
// Best-effort name from filename (Foo.csproj → "Foo").
|
|
712
|
+
const fname = path.split(/[/\\]/).pop() ?? '';
|
|
713
|
+
const name = fname.endsWith('.csproj') ? fname.slice(0, -'.csproj'.length) : null;
|
|
714
|
+
return {
|
|
715
|
+
path,
|
|
716
|
+
relativePath: normalizeRelative(root, path),
|
|
717
|
+
directory,
|
|
718
|
+
language: 'csharp',
|
|
719
|
+
runtime: 'dotnet',
|
|
720
|
+
name,
|
|
721
|
+
version: null,
|
|
722
|
+
dependencies: deps,
|
|
723
|
+
devDependencies: [],
|
|
724
|
+
scripts: [],
|
|
725
|
+
manifestType: '*.csproj',
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
631
729
|
function detectManifestsInDir(
|
|
632
730
|
dir: string,
|
|
633
731
|
root: string,
|
|
634
732
|
warnings: DetectionWarning[]
|
|
635
733
|
): PackageManifest[] {
|
|
734
|
+
// Plan 1.5.1: dispatch via the canonical MANIFEST_REGISTRY (single
|
|
735
|
+
// source-of-truth). The previous hand-rolled MANIFEST_FILES list +
|
|
736
|
+
// switch statement led to drift with runner.ts:buildDetectionSignals
|
|
737
|
+
// (Phoenix + ASP.NET were unreachable). Closed by registry.
|
|
738
|
+
// Lazy import to avoid ESM cycle; getManifestRegistry() is itself
|
|
739
|
+
// lazy-initialized.
|
|
740
|
+
const { getManifestRegistry, matchManifestPattern } = registryModule;
|
|
636
741
|
const out: PackageManifest[] = [];
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (!
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
break;
|
|
663
|
-
case 'pom.xml':
|
|
664
|
-
m = parsePomXml(path, dir, root, warnings);
|
|
665
|
-
break;
|
|
666
|
-
case 'build.gradle':
|
|
667
|
-
case 'build.gradle.kts':
|
|
668
|
-
m = parseBuildGradle(path, dir, root, warnings);
|
|
669
|
-
break;
|
|
670
|
-
case 'Gemfile':
|
|
671
|
-
m = parseGemfile(path, dir, root, warnings);
|
|
672
|
-
break;
|
|
742
|
+
let dirEntries: string[] | null = null;
|
|
743
|
+
for (const entry of getManifestRegistry()) {
|
|
744
|
+
if (!entry.pattern.startsWith('*')) {
|
|
745
|
+
// Exact-filename pattern: O(1) existence check.
|
|
746
|
+
const path = join(dir, entry.pattern);
|
|
747
|
+
if (!existsSync(path)) continue;
|
|
748
|
+
const m = entry.parse(path, dir, root, warnings);
|
|
749
|
+
if (m !== null) out.push(m);
|
|
750
|
+
} else {
|
|
751
|
+
// Extension-glob pattern: scan dir for matches. Lazy-readdir so
|
|
752
|
+
// we pay the cost only when at least one glob entry exists.
|
|
753
|
+
if (dirEntries === null) {
|
|
754
|
+
try {
|
|
755
|
+
dirEntries = readdirSync(dir);
|
|
756
|
+
} catch {
|
|
757
|
+
dirEntries = [];
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
for (const fname of dirEntries) {
|
|
761
|
+
if (!matchManifestPattern(fname, entry.pattern)) continue;
|
|
762
|
+
const path = join(dir, fname);
|
|
763
|
+
if (!existsSync(path)) continue;
|
|
764
|
+
const m = entry.parse(path, dir, root, warnings);
|
|
765
|
+
if (m !== null) out.push(m);
|
|
766
|
+
}
|
|
673
767
|
}
|
|
674
|
-
if (m !== null) out.push(m);
|
|
675
768
|
}
|
|
676
769
|
return out;
|
|
677
770
|
}
|
|
678
771
|
|
|
772
|
+
// Imported lazily-via-namespace to break the ESM cycle (manifest-registry
|
|
773
|
+
// imports parsers from THIS module). The namespace import is hoisted to
|
|
774
|
+
// the top of the module by ESM, but the named members are resolved on
|
|
775
|
+
// access — by the time `detectManifestsInDir` runs, the registry module's
|
|
776
|
+
// top-level evaluation has completed.
|
|
777
|
+
import * as registryModule from './manifest-registry.ts';
|
|
778
|
+
|
|
679
779
|
function listSubdirs(dir: string): string[] {
|
|
680
780
|
try {
|
|
681
781
|
return readdirSync(dir, { withFileTypes: true })
|
|
@@ -90,6 +90,9 @@ const EXTENSIONS: Record<SupportedLanguage, string[]> = {
|
|
|
90
90
|
go: ['go'],
|
|
91
91
|
java: ['java', 'kt'],
|
|
92
92
|
ruby: ['rb'],
|
|
93
|
+
// Plan 1.5.1 — closing CR-39 init gap for Phoenix + ASP.NET projects.
|
|
94
|
+
elixir: ['ex', 'exs'],
|
|
95
|
+
csharp: ['cs'],
|
|
93
96
|
};
|
|
94
97
|
|
|
95
98
|
const TEST_FILE_PATTERNS: Record<SupportedLanguage, RegExp[]> = {
|
|
@@ -101,6 +104,10 @@ const TEST_FILE_PATTERNS: Record<SupportedLanguage, RegExp[]> = {
|
|
|
101
104
|
go: [/_test\.go$/],
|
|
102
105
|
java: [/Test[^/]*\.(java|kt)$/, /[^/]*Test\.(java|kt)$/],
|
|
103
106
|
ruby: [/_spec\.rb$/, /_test\.rb$/],
|
|
107
|
+
// Phoenix/ExUnit canonical: `test/**_test.exs`. ASP.NET / xUnit
|
|
108
|
+
// canonical: `*Tests.cs` or `*.Tests/...`.
|
|
109
|
+
elixir: [/_test\.exs$/, /\/test\//],
|
|
110
|
+
csharp: [/Tests?\.cs$/, /\.Tests?\//],
|
|
104
111
|
};
|
|
105
112
|
|
|
106
113
|
const TEST_DIR_KEYWORDS = ['tests', 'test', '__tests__', 'spec', 'specs'];
|
|
@@ -245,6 +245,7 @@ function readConventions(cwd?: string): {
|
|
|
245
245
|
const configPath = join(projectRoot, 'massu.config.yaml');
|
|
246
246
|
if (!existsSync(configPath)) return defaults;
|
|
247
247
|
const content = readFileSync(configPath, 'utf-8');
|
|
248
|
+
// pattern-scanner-allow: yaml-parse — reason: compiled standalone hook (esbuild bundle). Per P2-023a, hooks cannot import getConfig() — they run in the Claude Code subprocess context with no module resolution path back to packages/core. Direct YAML parse is the only available access pattern.
|
|
248
249
|
const parsed = parseYaml(content) as Record<string, unknown> | null;
|
|
249
250
|
if (!parsed || typeof parsed !== 'object') return defaults;
|
|
250
251
|
const conventions = parsed.conventions as Record<string, unknown> | undefined;
|
|
@@ -279,6 +279,7 @@ async function buildDriftBanner(): Promise<string> {
|
|
|
279
279
|
const configPath = resolve(process.cwd(), 'massu.config.yaml');
|
|
280
280
|
if (!existsSync(configPath)) return '';
|
|
281
281
|
const content = readFileSync(configPath, 'utf-8');
|
|
282
|
+
// pattern-scanner-allow: yaml-parse — reason: compiled standalone hook (esbuild bundle). Per P2-023a, hooks cannot import getConfig() — they run in the Claude Code subprocess context with no module resolution path back to packages/core. Direct YAML parse is the only available access pattern.
|
|
282
283
|
const parsed = parseYaml(content) as Record<string, unknown> | null;
|
|
283
284
|
if (!parsed || typeof parsed !== 'object') return '';
|
|
284
285
|
const det = parsed.detection as Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic synchronous file-lock primitive built on `proper-lockfile`.
|
|
6
|
+
*
|
|
7
|
+
* Plan 3c gap-59 deliverable. Single source of truth for "acquire-lock,
|
|
8
|
+
* run fn, release on every exit path" across the codebase. Both
|
|
9
|
+
* `lib/installLock.ts:withInstallLock` (Plan 3a installAll serialization)
|
|
10
|
+
* AND `security/manifest-cache.ts:refreshManifest` (Plan 3c manifest
|
|
11
|
+
* cache writes) MUST delegate to `withFileLockSync` here — there is NO
|
|
12
|
+
* parallel lock implementation in the codebase per CR-46 / Rule 0
|
|
13
|
+
* (single-source-of-truth for lock semantics).
|
|
14
|
+
*
|
|
15
|
+
* What this primitive provides:
|
|
16
|
+
* 1. mkdirSync the lock parent dir if absent (fresh-repo / fresh-home case).
|
|
17
|
+
* 2. proper-lockfile.lockSync acquires the lock; we wrap the manual retry
|
|
18
|
+
* loop because lockSync rejects retries>0 (`Cannot use retries with
|
|
19
|
+
* the sync api` per node_modules/proper-lockfile/lib/adapter.js).
|
|
20
|
+
* 3. Surface ELOCKED (POSIX) and EBUSY (Windows) as the same FileLockBusyError.
|
|
21
|
+
* 4. Persist the lock-holder PID alongside the lock as `<lockPath>.pid` so
|
|
22
|
+
* the next contender can include it in a user-friendly error message.
|
|
23
|
+
* 5. Default 30s block-then-bail per Plan 3a §190; configurable per-callsite.
|
|
24
|
+
* 6. `errorFactory` opt lets callers customize the busy-error class so
|
|
25
|
+
* domain-specific helpers (`InstallLockBusyError`, future Phase 5
|
|
26
|
+
* `ManifestCacheBusyError`) can extend the base type without each
|
|
27
|
+
* re-implementing the lock logic.
|
|
28
|
+
*
|
|
29
|
+
* NOT provided by this primitive:
|
|
30
|
+
* - Async variant (`withFileLockAsync`). The current Phase 5 cache-write
|
|
31
|
+
* path resolves the async fetch BEFORE acquiring the lock, so the lock
|
|
32
|
+
* is held only during the brief sync atomicWrite. Async-while-holding-
|
|
33
|
+
* the-lock would deadlock under contention; the design is "fetch first,
|
|
34
|
+
* then lock-for-write only".
|
|
35
|
+
* - Reentrancy. `proper-lockfile.lockSync` is non-reentrant; calling
|
|
36
|
+
* withFileLockSync recursively from inside its own `fn` will fail with
|
|
37
|
+
* ELOCKED. Plan 3a observed and documented this in
|
|
38
|
+
* __tests__/watch/config-refresh-autoyes.test.ts:129.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
42
|
+
import { dirname } from 'path';
|
|
43
|
+
import * as lockfile from 'proper-lockfile';
|
|
44
|
+
|
|
45
|
+
export interface FileLockOpts {
|
|
46
|
+
/** Default 30s — proper-lockfile considers a lock stale after this elapses. */
|
|
47
|
+
staleMs?: number;
|
|
48
|
+
/**
|
|
49
|
+
* How long the manual retry loop should block waiting for the holder to
|
|
50
|
+
* release before bailing with the busy error. Default 30s.
|
|
51
|
+
* Pass `0` to bail immediately (used in tests).
|
|
52
|
+
*/
|
|
53
|
+
blockMs?: number;
|
|
54
|
+
/** Sleep granularity inside the retry loop. Default 100ms. */
|
|
55
|
+
pollIntervalMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Backwards-compat: legacy callers pass `retries: 0` to mean "do not
|
|
58
|
+
* block". When set to a positive integer, used by tests that want to
|
|
59
|
+
* exercise a specific retry count instead of the default time-based loop.
|
|
60
|
+
*/
|
|
61
|
+
retries?: number;
|
|
62
|
+
/** Override clock (test seam). */
|
|
63
|
+
now?: () => number;
|
|
64
|
+
/** Override sleep (test seam). Defaults to a busy-wait spinloop. */
|
|
65
|
+
sleep?: (ms: number) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Optional custom busy-error factory. When provided, the default
|
|
68
|
+
* FileLockBusyError throw is replaced with whatever this factory returns.
|
|
69
|
+
* Domain-specific callers (installLock, manifest-cache) use this to
|
|
70
|
+
* keep their own user-facing error types and messages.
|
|
71
|
+
*/
|
|
72
|
+
errorFactory?: (
|
|
73
|
+
lockPath: string,
|
|
74
|
+
holderPid: number | null,
|
|
75
|
+
retryAfterSeconds: number,
|
|
76
|
+
causeCode: string | undefined,
|
|
77
|
+
) => Error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class FileLockBusyError extends Error {
|
|
81
|
+
constructor(
|
|
82
|
+
public lockPath: string,
|
|
83
|
+
public holderPid: number | null,
|
|
84
|
+
public retryAfterSeconds: number,
|
|
85
|
+
public causeCode?: string,
|
|
86
|
+
) {
|
|
87
|
+
const pidPart = holderPid != null ? `(PID=${holderPid})` : '(PID=unknown)';
|
|
88
|
+
super(`File lock at ${lockPath} held by another process ${pidPart} — try again in ${retryAfterSeconds}s`);
|
|
89
|
+
this.name = 'FileLockBusyError';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Best-effort: read the PID of the current lock holder from the
|
|
95
|
+
* `<lockPath>.pid` sidecar file. Returns null on any read error.
|
|
96
|
+
*/
|
|
97
|
+
export function readLockHolderPid(lockPath: string): number | null {
|
|
98
|
+
try {
|
|
99
|
+
const raw = readFileSync(`${lockPath}.pid`, 'utf-8').trim();
|
|
100
|
+
const pid = Number.parseInt(raw, 10);
|
|
101
|
+
if (!Number.isFinite(pid) || pid <= 0) return null;
|
|
102
|
+
return pid;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* CR-9 audit L3 fix: REQUIRES SharedArrayBuffer + Atomics for the sync
|
|
110
|
+
* wait. The prior fallback (a tight `while (Date.now() < end)` spinloop)
|
|
111
|
+
* burned 100% CPU on environments without Atomics — typically sandboxed
|
|
112
|
+
* serverless runtimes — making contended manifest refreshes a DoS
|
|
113
|
+
* surface against the host. With this throw, callers running on
|
|
114
|
+
* Atomics-less environments fail loudly + early instead of silently
|
|
115
|
+
* spinning.
|
|
116
|
+
*/
|
|
117
|
+
export function busyWaitSync(ms: number): void {
|
|
118
|
+
if (typeof SharedArrayBuffer === 'undefined' || typeof Atomics === 'undefined') {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`withFileLockSync requires SharedArrayBuffer + Atomics for its retry-loop wait. ` +
|
|
121
|
+
`This Node runtime does not provide them — refusing to fall back to a CPU spinloop. ` +
|
|
122
|
+
`If you hit this in a sandboxed serverless env, the fix is to perform the ` +
|
|
123
|
+
`lock-protected operation in a host runtime that supports Atomics.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const sab = new SharedArrayBuffer(4);
|
|
127
|
+
const view = new Int32Array(sab);
|
|
128
|
+
Atomics.wait(view, 0, 0, ms);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Acquire the lock at `lockPath`, run `fn`, release on every exit path.
|
|
133
|
+
* Synchronous all the way through. See module-level doc for guarantees.
|
|
134
|
+
*
|
|
135
|
+
* Throws:
|
|
136
|
+
* - The result of `opts.errorFactory(...)` if provided AND lock is busy
|
|
137
|
+
* beyond `blockMs`. Otherwise throws FileLockBusyError.
|
|
138
|
+
* - Any non-ELOCKED/EBUSY filesystem error from proper-lockfile is
|
|
139
|
+
* re-thrown unchanged.
|
|
140
|
+
*/
|
|
141
|
+
export function withFileLockSync<T>(lockPath: string, fn: () => T, opts: FileLockOpts = {}): T {
|
|
142
|
+
// Ensure the lock's parent directory exists. Fresh repos / fresh user-home
|
|
143
|
+
// .massu/ may not have the parent yet.
|
|
144
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
145
|
+
|
|
146
|
+
const staleMs = opts.staleMs ?? 30_000;
|
|
147
|
+
const blockMs = opts.retries === 0 ? 0 : (opts.blockMs ?? 30_000);
|
|
148
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 100;
|
|
149
|
+
const now = opts.now ?? Date.now;
|
|
150
|
+
const sleep = opts.sleep ?? busyWaitSync;
|
|
151
|
+
const makeBusyError =
|
|
152
|
+
opts.errorFactory ??
|
|
153
|
+
((path, pid, retrySeconds, code) => new FileLockBusyError(path, pid, retrySeconds, code));
|
|
154
|
+
|
|
155
|
+
let release: (() => void) | null = null;
|
|
156
|
+
const deadline = now() + blockMs;
|
|
157
|
+
|
|
158
|
+
for (;;) {
|
|
159
|
+
try {
|
|
160
|
+
release = lockfile.lockSync(lockPath, {
|
|
161
|
+
stale: staleMs,
|
|
162
|
+
retries: 0,
|
|
163
|
+
realpath: false,
|
|
164
|
+
});
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(`${lockPath}.pid`, String(process.pid), 'utf-8');
|
|
167
|
+
} catch {
|
|
168
|
+
// best-effort
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const e = err as NodeJS.ErrnoException;
|
|
173
|
+
const code = e.code;
|
|
174
|
+
if (code !== 'ELOCKED' && code !== 'EBUSY') {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
if (now() >= deadline) {
|
|
178
|
+
const holderPid = readLockHolderPid(lockPath);
|
|
179
|
+
const remainingMs = Math.max(0, deadline - now());
|
|
180
|
+
const retryAfterSeconds = blockMs === 0
|
|
181
|
+
? Math.round(staleMs / 1000)
|
|
182
|
+
: Math.round(remainingMs / 1000);
|
|
183
|
+
throw makeBusyError(lockPath, holderPid, retryAfterSeconds, code);
|
|
184
|
+
}
|
|
185
|
+
sleep(pollIntervalMs);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
return fn();
|
|
191
|
+
} finally {
|
|
192
|
+
try {
|
|
193
|
+
if (release) release();
|
|
194
|
+
} catch {
|
|
195
|
+
// best-effort
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
rmSync(`${lockPath}.pid`, { force: true });
|
|
199
|
+
} catch {
|
|
200
|
+
// best-effort
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|