@event4u/agent-config 17.2.1 → 17.4.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/skills/install-agent-config/SKILL.md +9 -9
- package/CHANGELOG.md +167 -193
- package/CREDITS.md +2 -2
- package/README.md +1 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +16 -12
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/check_memory.ts +1 -1
- package/dist/cli/commands/recordTriggerEval.js +1 -1
- package/dist/cli/registry.js +2 -2
- package/dist/cli-delegate/{chunk-KR5ZIFPO.js → chunk-5AYJGUA3.js} +1 -3
- package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
- package/dist/cli-delegate/chunk-E7ACTJTJ.js +6792 -0
- package/dist/cli-delegate/{chunk-VYZGJLCA.js → chunk-RKNT4LIK.js} +22 -1286
- package/dist/cli-delegate/{chunk-BZJ4Y3CI.js → chunk-WY5ZZPGL.js} +2 -2
- package/dist/cli-delegate/{chunk-CMZEKSNN.js → chunk-X6LZ7W5A.js} +73 -10
- package/dist/cli-delegate/cmd_conformance.js +190 -192
- package/dist/cli-delegate/cmd_doctor.js +5 -5
- package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +1 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +8 -4
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_update.js +24 -13
- package/dist/cli-delegate/cmd_upgrade.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
- package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
- package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
- package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +3 -3
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/code_graph_refresh.chunk.cjs +443 -0
- package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -0
- package/dist/hooks/dispatch.js +1001 -938
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/egress_engine.chunk.cjs +57 -7
- package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
- package/dist/hooks/update_notice.chunk.cjs +914 -0
- package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
- package/dist/install/install.mjs +6974 -6037
- package/dist/mcp/mcp-cloudflare-catalogue.json +1 -0
- package/dist/mcp/registry-manifest.json +3 -2
- package/dist/mcp/server.json +13 -4
- package/dist/mcp/server.mjs +71202 -63282
- package/dist/scripts/_lib/egress_rules.js +8 -2
- package/dist/scripts/condense.js +18 -15
- package/dist/server/routes/discovery.js +1 -1
- package/dist/server/routes/install.js +1 -1
- package/dist/server/routes/ping.js +1 -1
- package/dist/server/routes/schema.js +4 -4
- package/dist/server/routes/settings.js +2 -6
- package/dist/server/routes/wizard.js +6 -6
- package/dist/server/schemas/settings.js +42 -42
- package/dist/shared/settingsSurface.js +1 -1
- package/dist/shared/userMd/schema.js +1 -1
- package/dist/shared/zodJsonSchema.js +47 -0
- package/dist/ui/assets/index-B0bcIgi0.js +77 -0
- package/dist/ui/index.html +1 -1
- package/package.json +6 -3
- package/src/agent-src/templates/scripts/check_memory.ts +1 -1
- package/src/config/ci-local-parity.yml +11 -0
- package/src/config/continuity-surface.json +9 -2
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/gitignore-block.txt +6 -5
- package/src/config/hook-bundle-budget.json +46 -6
- package/src/config/pack-size-budget.json +12 -1
- package/src/config/packed-binary-manifest.json +18 -0
- package/src/config/sigstore/npm_keys.json +50 -0
- package/src/config/sigstore/trust-material.json +9 -0
- package/src/config/sigstore/trusted_root.json +126 -0
- package/src/config/skill-body-budget.json +7 -1
- package/src/scripts/_cli/cmd_settings_set.ts +3 -2
- package/src/scripts/_cli/cmd_update.ts +48 -20
- package/src/scripts/_cli/cmd_upgrade.ts +7 -1
- package/src/scripts/_cli/upgrade_unattended.ts +90 -76
- package/src/scripts/_dispatch.bash +4 -3
- package/src/scripts/_lib/confusables.ts +0 -19
- package/src/scripts/_lib/egress_normalize.ts +53 -23
- package/src/scripts/_lib/egress_rules.ts +8 -2
- package/src/scripts/_lib/egress_text.ts +5 -3
- package/src/scripts/_lib/egress_token.ts +7 -5
- package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
- package/src/scripts/_lib/git_common_dir.ts +10 -0
- package/src/scripts/_lib/graph_feeder_record.ts +3 -2
- package/src/scripts/_lib/install_reach_checks.ts +32 -2
- package/src/scripts/_lib/lineage_backfill.ts +136 -0
- package/src/scripts/_lib/lineage_ledger.ts +211 -0
- package/src/scripts/_lib/lineage_map.ts +55 -0
- package/src/scripts/_lib/lineage_query.ts +140 -0
- package/src/scripts/_lib/lineage_seed.ts +116 -0
- package/src/scripts/_lib/lineage_watch.ts +249 -0
- package/src/scripts/_lib/link_crypto.ts +8 -4
- package/src/scripts/_lib/listing_manifests.ts +85 -0
- package/src/scripts/_lib/neighbour_census.ts +1 -0
- package/src/scripts/_lib/production_reach.ts +3 -3
- package/src/scripts/_lib/project_registry.ts +227 -0
- package/src/scripts/_lib/provenance_verify.ts +14 -242
- package/src/scripts/_lib/release_settings_surface.ts +2 -6
- package/src/scripts/_lib/sigstore_provenance.ts +177 -0
- package/src/scripts/_lib/source_id.ts +48 -0
- package/src/scripts/_lib/source_shape.ts +4 -0
- package/src/scripts/_lib/unattended_gates.ts +6 -1
- package/src/scripts/adoption_status.ts +231 -27
- package/src/scripts/advisories.ts +26 -10
- package/src/scripts/ai_council/council_egress.ts +5 -6
- package/src/scripts/bench_graph_feeder_latency.ts +10 -3
- package/src/scripts/build_mcp_registry_manifest.ts +23 -12
- package/src/scripts/check_dependency_floors.ts +14 -0
- package/src/scripts/check_egress_mutations.ts +1 -0
- package/src/scripts/check_release_manifest_content.ts +17 -2
- package/src/scripts/check_release_pr_shape.ts +5 -0
- package/src/scripts/code_graph/build.ts +145 -45
- package/src/scripts/code_graph/cli.ts +382 -35
- package/src/scripts/code_graph/conventions_default.ts +247 -0
- package/src/scripts/code_graph/detect.ts +5 -5
- package/src/scripts/code_graph/estate_cli.ts +135 -0
- package/src/scripts/code_graph/estate_index.ts +394 -0
- package/src/scripts/code_graph/estate_manifests.ts +223 -0
- package/src/scripts/code_graph/estate_map.ts +141 -0
- package/src/scripts/code_graph/extract.ts +8 -2
- package/src/scripts/code_graph/extract_more.ts +241 -2
- package/src/scripts/code_graph/graph_budget.ts +158 -0
- package/src/scripts/code_graph/graph_home.ts +80 -0
- package/src/scripts/code_graph/lineage_cli.ts +315 -0
- package/src/scripts/code_graph/loader.ts +10 -2
- package/src/scripts/code_graph/map.ts +195 -53
- package/src/scripts/code_graph/map_model.ts +113 -20
- package/src/scripts/code_graph/map_verify.ts +80 -5
- package/src/scripts/code_graph/map_viewer/viewer.css +18 -0
- package/src/scripts/code_graph/map_viewer/viewer.js +146 -15
- package/src/scripts/code_graph/passes.ts +173 -0
- package/src/scripts/code_graph/project_ref.ts +25 -0
- package/src/scripts/code_graph/projects_cli.ts +254 -0
- package/src/scripts/code_graph/providers/angular.ts +55 -0
- package/src/scripts/code_graph/providers/aspnetcore.ts +182 -0
- package/src/scripts/code_graph/providers/catalog.ts +299 -0
- package/src/scripts/code_graph/providers/django.ts +206 -0
- package/src/scripts/code_graph/providers/express.ts +25 -0
- package/src/scripts/code_graph/providers/fastapi.ts +49 -0
- package/src/scripts/code_graph/providers/flask.ts +99 -0
- package/src/scripts/code_graph/providers/go_http.ts +121 -0
- package/src/scripts/code_graph/providers/index.ts +103 -0
- package/src/scripts/code_graph/{laravel_overlay.ts → providers/laravel.ts} +113 -7
- package/src/scripts/code_graph/providers/nestjs.ts +34 -0
- package/src/scripts/code_graph/providers/nextjs.ts +91 -0
- package/src/scripts/code_graph/providers/nuxt.ts +55 -0
- package/src/scripts/code_graph/providers/rails.ts +204 -0
- package/src/scripts/code_graph/providers/react-router.ts +58 -0
- package/src/scripts/code_graph/providers/shared/detect.ts +78 -0
- package/src/scripts/code_graph/providers/shared/extractors.ts +249 -0
- package/src/scripts/code_graph/providers/shared/js.ts +252 -0
- package/src/scripts/code_graph/providers/shared/kit.ts +210 -0
- package/src/scripts/code_graph/providers/shared/php.ts +64 -0
- package/src/scripts/code_graph/providers/shared/python.ts +223 -0
- package/src/scripts/code_graph/providers/spring.ts +175 -0
- package/src/scripts/code_graph/providers/sqlalchemy.ts +62 -0
- package/src/scripts/code_graph/providers/sveltekit.ts +59 -0
- package/src/scripts/code_graph/providers/symfony.ts +142 -0
- package/src/scripts/code_graph/providers/typeorm.ts +58 -0
- package/src/scripts/code_graph/providers/types.ts +56 -0
- package/src/scripts/code_graph/providers/vue-router.ts +21 -0
- package/src/scripts/code_graph/providers/wordpress.ts +128 -0
- package/src/scripts/code_graph/query.ts +9 -9
- package/src/scripts/code_graph/refresh_cli.ts +81 -0
- package/src/scripts/code_graph/refresh_lock.ts +197 -0
- package/src/scripts/code_graph/repo_identity.ts +169 -0
- package/src/scripts/code_graph/resolution_tiers.ts +19 -7
- package/src/scripts/code_graph/scip_reader.ts +2 -2
- package/src/scripts/code_graph/sqlite_store.ts +31 -28
- package/src/scripts/code_graph/types.ts +79 -41
- package/src/scripts/code_graph/validate.ts +6 -8
- package/src/scripts/condense.ts +15 -15
- package/src/scripts/generate_settings_reference.ts +2 -6
- package/src/scripts/hook_chunks.mjs +11 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +43 -23
- package/src/scripts/hooks/code_graph_refresh_hook.ts +163 -0
- package/src/scripts/hooks/dispatch_hook.ts +6 -7
- package/src/scripts/hooks/host_semantics.ts +72 -0
- package/src/scripts/hooks/lazy_concern.ts +8 -0
- package/src/scripts/hooks/user_notice.ts +105 -0
- package/src/scripts/install-hooks.sh +54 -33
- package/src/scripts/install.ts +2 -6
- package/src/scripts/lint_harvest_provenance.ts +137 -11
- package/src/scripts/lint_hook_manifest.ts +2 -0
- package/src/scripts/lint_marketplace.ts +33 -0
- package/src/scripts/lint_mcp_registry_manifest.ts +36 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +70 -15
- package/src/scripts/mcp_server/graph_tools.ts +165 -41
- package/src/scripts/network_sources.ts +24 -6
- package/src/scripts/release.ts +2 -5
- package/src/scripts/release_drill.ts +4 -1
- package/src/scripts/release_env.ts +17 -0
- package/src/scripts/release_mix_taxonomy.json +3 -0
- package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
- package/src/scripts/update_notice_hook.ts +75 -7
- package/src/server/schemas/settings.ts +42 -42
- package/src/shared/settingsSurface.ts +1 -1
- package/src/shared/userMd/schema.ts +1 -1
- package/src/shared/zodJsonSchema.ts +41 -0
- package/src/vendor/grammars/README.md +8 -6
- package/src/vendor/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
- package/dist/cli-delegate/chunk-XIAPY44N.js +0 -4587
- package/dist/ui/assets/index-w70Ado09.js +0 -23
- /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
- /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeORM, in files that import from `typeorm` (MikroORM shares `@Entity`):
|
|
3
|
+
*
|
|
4
|
+
* @Entity('t') · @Entity({ name: 't' }) · @Entity() class --persists--> table:t
|
|
5
|
+
* (bare: snake_case of the class, TypeORM's default naming)
|
|
6
|
+
* @ManyToOne(() => X) · @OneToMany(() => X, …) class --relates--> X
|
|
7
|
+
* queryRunner.createTable(new Table({ name: 't' })) migration --migrates--> table:t
|
|
8
|
+
*
|
|
9
|
+
* A custom naming strategy, `@ViewEntity`, string-named relation targets and
|
|
10
|
+
* `EntitySchema` definitions are not followed.
|
|
11
|
+
*/
|
|
12
|
+
import { npmRoots } from './shared/detect.js';
|
|
13
|
+
import { scopeKit } from './shared/js.js';
|
|
14
|
+
import { lineIndex, makeKit } from './shared/kit.js';
|
|
15
|
+
import type { ConventionProvider } from './types.js';
|
|
16
|
+
|
|
17
|
+
const EXTS = ['.ts', '.js'];
|
|
18
|
+
|
|
19
|
+
/** TypeORM's DefaultNamingStrategy table name: `UserProfile` → `user_profile`, `HTTPLog` → `http_log`. */
|
|
20
|
+
export function typeormTableName(className: string): string {
|
|
21
|
+
return className
|
|
22
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
23
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
24
|
+
.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const typeormProvider: ConventionProvider = {
|
|
28
|
+
id: 'typeorm',
|
|
29
|
+
detect: (probe) => npmRoots(probe, ['typeorm']),
|
|
30
|
+
build: (input) => {
|
|
31
|
+
const kit = scopeKit(makeKit(input), (src) => /\bfrom\s+['"]typeorm['"]|require\(\s*['"]typeorm['"]\s*\)/.test(src));
|
|
32
|
+
for (const f of kit.filesWithExt(...EXTS)) {
|
|
33
|
+
const at = lineIndex(f.source);
|
|
34
|
+
for (const m of f.source.matchAll(/@Entity\(\s*(?:(['"`])(?<name>[^'"`]+)\1|\{(?<opts>[^}]*)\})?/g)) {
|
|
35
|
+
const cls = kit.nextSymbol(f.path, at(m.index as number), ['class']);
|
|
36
|
+
if (!cls || cls.kind !== 'class') continue;
|
|
37
|
+
const fromOpts = /\bname\s*:\s*(['"`])([^'"`]+)\1/.exec(m.groups?.['opts'] ?? '')?.[2];
|
|
38
|
+
const name = m.groups?.['name'] ?? fromOpts ?? typeormTableName(cls.label);
|
|
39
|
+
kit.emit(cls.id, kit.table(name, f.path, cls.source_location[0] as number), 'persists', 'schema-call');
|
|
40
|
+
}
|
|
41
|
+
const relation = /@(?:ManyToOne|OneToMany|OneToOne|ManyToMany)\(\s*(?:\(\s*\w*\s*\)|\w+)\s*=>\s*([A-Za-z_$][\w$]*)/g;
|
|
42
|
+
for (const m of f.source.matchAll(relation)) {
|
|
43
|
+
const owner = kit.classAt(f.path, at(m.index as number));
|
|
44
|
+
const target = kit.resolveName(f.path, m[1] as string);
|
|
45
|
+
if (!owner || !target || target === owner.id || kit.nodeById.get(target)?.kind !== 'class') continue;
|
|
46
|
+
kit.emit(owner.id, target, 'relates', 'orm-relation');
|
|
47
|
+
}
|
|
48
|
+
const create = /new\s+Table\(\s*\{\s*name\s*:\s*(['"`])(?<name>[^'"`]+)\1|CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(?<raw>\w+)/gi;
|
|
49
|
+
if (/\bMigrationInterface\b/.test(f.source)) {
|
|
50
|
+
for (const m of f.source.matchAll(create)) {
|
|
51
|
+
const name = m.groups?.['name'] ?? m.groups?.['raw'];
|
|
52
|
+
if (name) kit.emit(f.path, kit.table(name, f.path, at(m.index as number)), 'migrates', 'schema-call');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return kit.result();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider seam (ADR-298): everything a framework links by convention
|
|
3
|
+
* reaches the graph through a registered provider, never through a call the
|
|
4
|
+
* build hard-wires. The core imports providers only through `./index.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import type { SourceFile } from '../build.js';
|
|
7
|
+
import type { FileRole } from '../map_model.js';
|
|
8
|
+
import type { ResolutionConfig } from '../resolution_tiers.js';
|
|
9
|
+
import type { CodeEdge, CodeNode } from '../types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a provider may read to decide whether it applies: manifests and marker
|
|
13
|
+
* files, never file suffixes — `.php` does not mean any one framework.
|
|
14
|
+
*/
|
|
15
|
+
export interface ProjectProbe {
|
|
16
|
+
/** Manifest files by repo-relative POSIX path → text. */
|
|
17
|
+
manifests: ReadonlyMap<string, string>;
|
|
18
|
+
/** Repo-relative paths of marker files that carry no text worth reading (an entry script). */
|
|
19
|
+
markers: ReadonlySet<string>;
|
|
20
|
+
/** Every project path, sorted, code or not (templates, assets); empty when unknown. */
|
|
21
|
+
paths: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const EMPTY_PROBE: ProjectProbe = { manifests: new Map(), markers: new Set(), paths: [] };
|
|
25
|
+
|
|
26
|
+
export interface ProviderInput {
|
|
27
|
+
files: readonly SourceFile[];
|
|
28
|
+
baseNodes: readonly CodeNode[];
|
|
29
|
+
/** The graph's edges so far, read-only (import bindings, for resolving a handler name). */
|
|
30
|
+
baseEdges: readonly CodeEdge[];
|
|
31
|
+
/** Every project path under the provider's roots, code or not (templates). */
|
|
32
|
+
paths: readonly string[];
|
|
33
|
+
/** The manifests under the provider's roots, by repo-relative path → text. */
|
|
34
|
+
manifests: ReadonlyMap<string, string>;
|
|
35
|
+
resolution: ResolutionConfig;
|
|
36
|
+
/** Package roots the provider was detected in; `''` is the repository root. */
|
|
37
|
+
roots: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProviderResult {
|
|
41
|
+
nodes: CodeNode[];
|
|
42
|
+
edges: CodeEdge[];
|
|
43
|
+
/** Base edges the provider proves false; applied only when it drew something. */
|
|
44
|
+
contest?: (e: CodeEdge) => boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ConventionProvider {
|
|
48
|
+
id: string;
|
|
49
|
+
/** Basenames of marker files whose presence the probe should record (an entry script). */
|
|
50
|
+
markers?: readonly string[];
|
|
51
|
+
/** Package roots this provider applies to; empty when it does not. */
|
|
52
|
+
detect(probe: ProjectProbe): string[];
|
|
53
|
+
build(input: ProviderInput): ProviderResult;
|
|
54
|
+
/** A role the map gives a file by this provider's conventions. */
|
|
55
|
+
roleOf?(path: string): FileRole | undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue Router: route objects `{ path: '/x', component: X }` or
|
|
3
|
+
* `component: () => import('./views/X.vue')` → PAGE /x --routes-to--> the
|
|
4
|
+
* component the file names or the single-file component it imports. Child
|
|
5
|
+
* paths are not joined to their parent's; `components: { default: … }` named
|
|
6
|
+
* views and alias specifiers (`@/views/X.vue`) are not followed.
|
|
7
|
+
*/
|
|
8
|
+
import { npmRoots } from './shared/detect.js';
|
|
9
|
+
import { routeObjects } from './shared/js.js';
|
|
10
|
+
import { makeKit } from './shared/kit.js';
|
|
11
|
+
import type { ConventionProvider } from './types.js';
|
|
12
|
+
|
|
13
|
+
export const vueRouterProvider: ConventionProvider = {
|
|
14
|
+
id: 'vue-router',
|
|
15
|
+
detect: (probe) => npmRoots(probe, ['vue-router']),
|
|
16
|
+
build: (input) => {
|
|
17
|
+
const kit = makeKit(input);
|
|
18
|
+
routeObjects(kit, { exts: ['.ts', '.js', '.mjs'], componentKeys: ['component'] });
|
|
19
|
+
return kit.result();
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress: hooks and theme parts, which no import or call states.
|
|
3
|
+
*
|
|
4
|
+
* add_action('init', 'cb') · add_filter(…) route `HOOK init` --routes-to--> the callback
|
|
5
|
+
* add_shortcode('card', 'cb') route `SHORTCODE card` --routes-to--> the callback
|
|
6
|
+
* get_template_part('parts/card'[, 'wide']) caller --renders--> parts/card(-wide).php
|
|
7
|
+
*
|
|
8
|
+
* A hook name is no URL, so its route path is the name as written, without the
|
|
9
|
+
* leading slash an HTTP route carries. A callback resolves as a function of the
|
|
10
|
+
* same file, else the one function of that name in the tree; `[$this, 'm']`
|
|
11
|
+
* resolves to the enclosing class's method, `[X::class, 'm']` to X's method
|
|
12
|
+
* only when X is the class the file's namespace names. A closure, a dynamic
|
|
13
|
+
* hook name or an ambiguous function gets no edge. `do_action` fires a hook and
|
|
14
|
+
* declares nothing.
|
|
15
|
+
*
|
|
16
|
+
* Detection: a `wp-config.php` marker, a composer manifest requiring a
|
|
17
|
+
* WordPress core package or a wpackagist plugin/theme, or a composer manifest
|
|
18
|
+
* whose `type` is a WordPress plugin, theme or must-use plugin. A plugin or theme
|
|
19
|
+
* shipping no composer manifest carries no signal and stays undetected.
|
|
20
|
+
*/
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import type { CodeNode } from '../types.js';
|
|
24
|
+
import { markerRoots, union } from './shared/detect.js';
|
|
25
|
+
import { type Kit, lineIndex, makeKit } from './shared/kit.js';
|
|
26
|
+
import { classNode, phpScope, qualify, shortName } from './shared/php.js';
|
|
27
|
+
import type { ConventionProvider, ProjectProbe } from './types.js';
|
|
28
|
+
import type { ResolutionConfig } from '../resolution_tiers.js';
|
|
29
|
+
|
|
30
|
+
const CORE = /^(?:johnpbloch\/wordpress(?:-core)?|roots\/wordpress(?:-no-content)?|roots\/bedrock|wpackagist-(?:plugin|theme)\/.+)$/;
|
|
31
|
+
const TYPES = new Set(['wordpress-plugin', 'wordpress-theme', 'wordpress-muplugin']);
|
|
32
|
+
|
|
33
|
+
function detectWordpress(probe: ProjectProbe): string[] {
|
|
34
|
+
const roots: string[] = [];
|
|
35
|
+
for (const [p, text] of probe.manifests) {
|
|
36
|
+
if (path.posix.basename(p) !== 'composer.json') continue;
|
|
37
|
+
try {
|
|
38
|
+
const j = JSON.parse(text) as { name?: string; type?: string; require?: Record<string, string>; 'require-dev'?: Record<string, string> };
|
|
39
|
+
const deps = [...Object.keys(j.require ?? {}), ...Object.keys(j['require-dev'] ?? {})];
|
|
40
|
+
if (TYPES.has(j.type ?? '') || j.name === 'roots/bedrock' || deps.some((d) => CORE.test(d))) {
|
|
41
|
+
const d = path.posix.dirname(p);
|
|
42
|
+
roots.push(d === '.' ? '' : d);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return union(roots, markerRoots(probe, 'wp-config.php'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const REGISTRATION = /\b(?<fn>add_action|add_filter|add_shortcode)\(\s*(['"])(?<name>[\w\-/.:]+)\2\s*,\s*/g;
|
|
52
|
+
|
|
53
|
+
function callback(kit: Kit, file: string, src: string, at: number, line: number, functions: Map<string, CodeNode[]>, resolution: ResolutionConfig): string | null {
|
|
54
|
+
const rest = src.slice(at, at + 300);
|
|
55
|
+
const fn = /^(['"])\\?(?:[\w\\]+\\)?(?<name>\w+)\1/.exec(rest)?.groups?.['name'];
|
|
56
|
+
if (fn) {
|
|
57
|
+
const local = (functions.get(fn) ?? []).filter((n) => n.source_file === file);
|
|
58
|
+
if (local.length === 1) return (local[0] as CodeNode).id;
|
|
59
|
+
const all = functions.get(fn) ?? [];
|
|
60
|
+
return all.length === 1 ? (all[0] as CodeNode).id : null;
|
|
61
|
+
}
|
|
62
|
+
const pair = /^(?:\[|array\()\s*(?<recv>\$this|self::class|static::class|__CLASS__|[\\\w]+::class|(['"])[\\\w]+\2)\s*,\s*(['"])(?<member>\w+)\3/.exec(rest);
|
|
63
|
+
if (!pair?.groups) return null;
|
|
64
|
+
const recv = pair.groups['recv'] as string;
|
|
65
|
+
const member = pair.groups['member'] as string;
|
|
66
|
+
let cls: string | null = null;
|
|
67
|
+
if (/^(?:\$this|self::class|static::class|__CLASS__)$/.test(recv)) cls = kit.classAt(file, line)?.id ?? null;
|
|
68
|
+
else {
|
|
69
|
+
const ref = recv.replace(/::class$/, '').replace(/^['"]|['"]$/g, '');
|
|
70
|
+
const fq = /^['"]/.test(recv) ? ref.replace(/^\\/, '') : qualify(ref, phpScope(src));
|
|
71
|
+
cls = classNode(kit, fq, resolution) ?? classByNamespace(kit, fq);
|
|
72
|
+
}
|
|
73
|
+
return cls ? kit.methodOf(cls, member) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The one class node whose short name and declaring file's namespace spell `fq`. */
|
|
77
|
+
function classByNamespace(kit: Kit, fq: string): string | null {
|
|
78
|
+
const short = shortName(fq);
|
|
79
|
+
const hits = kit.nodesNamed(short).filter((n) => {
|
|
80
|
+
if (n.kind !== 'class') return false;
|
|
81
|
+
const f = kit.files.find((x) => x.path === n.source_file);
|
|
82
|
+
const ns = f ? phpScope(f.source).namespace : '';
|
|
83
|
+
return (ns ? `${ns}\\${short}` : short) === fq;
|
|
84
|
+
});
|
|
85
|
+
return hits.length === 1 ? (hits[0] as CodeNode).id : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `slug[-name].php`, preferring a part under a directory that holds the calling file (its theme). */
|
|
89
|
+
function templatePart(kit: Kit, caller: string, slug: string, name: string | undefined): string | null {
|
|
90
|
+
for (const rel of name ? [`${slug}-${name}.php`, `${slug}.php`] : [`${slug}.php`]) {
|
|
91
|
+
const hits = kit.paths.filter((p) => p === rel || p.endsWith(`/${rel}`));
|
|
92
|
+
const own = hits.filter((p) => caller.startsWith(p.slice(0, p.length - rel.length)));
|
|
93
|
+
if (own.length === 1) return own[0] as string;
|
|
94
|
+
if (own.length === 0 && hits.length === 1) return hits[0] as string;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const wordpressProvider: ConventionProvider = {
|
|
100
|
+
id: 'wordpress',
|
|
101
|
+
markers: ['wp-config.php'],
|
|
102
|
+
detect: detectWordpress,
|
|
103
|
+
build: (input) => {
|
|
104
|
+
const kit = makeKit(input);
|
|
105
|
+
const functions = new Map<string, CodeNode[]>();
|
|
106
|
+
for (const n of input.baseNodes) {
|
|
107
|
+
if (n.kind !== 'function' || !n.source_file.endsWith('.php')) continue;
|
|
108
|
+
functions.set(n.label, [...(functions.get(n.label) ?? []), n]);
|
|
109
|
+
}
|
|
110
|
+
for (const f of kit.filesWithExt('.php')) {
|
|
111
|
+
const at = lineIndex(f.source);
|
|
112
|
+
for (const m of f.source.matchAll(REGISTRATION)) {
|
|
113
|
+
const line = at(m.index as number);
|
|
114
|
+
const verb = m.groups?.['fn'] === 'add_shortcode' ? 'SHORTCODE' : 'HOOK';
|
|
115
|
+
const id = kit.route(f.path, verb, m.groups?.['name'] as string, line);
|
|
116
|
+
const target = callback(kit, f.path, f.source, (m.index as number) + m[0].length, line, functions, input.resolution);
|
|
117
|
+
if (target) kit.emit(id, target, 'routes-to', 'route-table');
|
|
118
|
+
}
|
|
119
|
+
for (const m of f.source.matchAll(/\bget_template_part\(\s*(['"])(?<slug>[\w\-/]+)\1\s*(?:,\s*(['"])(?<name>[\w-]+)\3)?/g)) {
|
|
120
|
+
const target = templatePart(kit, f.path, m.groups?.['slug'] as string, m.groups?.['name']);
|
|
121
|
+
if (!target) continue;
|
|
122
|
+
kit.fileNode(target);
|
|
123
|
+
kit.emit(kit.enclosing(f.path, at(m.index as number)), target, 'renders', 'directive-scan');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return kit.result();
|
|
127
|
+
},
|
|
128
|
+
};
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
emitSqliteTwin,
|
|
14
14
|
type GraphIndex,
|
|
15
15
|
INDEXED_READ_MIN_EDGES,
|
|
16
|
-
loadSerializedFromTwin,
|
|
17
16
|
openGraphIndex,
|
|
18
17
|
} from './sqlite_store.js';
|
|
19
18
|
import type { CodeEdge, CodeGraph, CodeNode, Relation } from './types.js';
|
|
@@ -132,6 +131,9 @@ export function loadGraph(graphPath: string, source = graphPath): LoadedGraph {
|
|
|
132
131
|
// used to be preferred and still returned the entire serialization, which
|
|
133
132
|
// made it a cheaper blob transport rather than an index.
|
|
134
133
|
const index = openGraphIndex(graphPath);
|
|
134
|
+
// `openGraphIndex` declines on exactly the conditions under which the twin
|
|
135
|
+
// is stale, absent or corrupt — so a handle here means it needs no re-emit.
|
|
136
|
+
const twinCurrent = index !== null;
|
|
135
137
|
if (index) {
|
|
136
138
|
if (index.edgeCount >= INDEXED_READ_MIN_EDGES) return indexBackedGraph(index, source, graphPath);
|
|
137
139
|
// Small graph: the in-memory path is cheaper. Release the handle rather
|
|
@@ -139,12 +141,10 @@ export function loadGraph(graphPath: string, source = graphPath): LoadedGraph {
|
|
|
139
141
|
index.close();
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
const fromTwin = loadSerializedFromTwin(graphPath);
|
|
147
|
-
const raw = fromTwin ?? fs.readFileSync(graphPath, 'utf-8');
|
|
144
|
+
// Below the threshold the JSON is parsed. The twin no longer carries a
|
|
145
|
+
// second copy of it to read instead; a missing or stale twin is re-emitted
|
|
146
|
+
// below (zero-touch rebuild-on-drift, ADR-129 Phase 7).
|
|
147
|
+
const raw = fs.readFileSync(graphPath, 'utf-8');
|
|
148
148
|
const parsed = JSON.parse(raw) as unknown;
|
|
149
149
|
// A consumer's own graph file, written by another tool, is DETECTED by
|
|
150
150
|
// `detect.ts` and used to throw here — detected-but-unloadable, the worst
|
|
@@ -166,8 +166,8 @@ export function loadGraph(graphPath: string, source = graphPath): LoadedGraph {
|
|
|
166
166
|
// The twin is a cache of a file's bytes keyed on its own checksum. An
|
|
167
167
|
// adapted graph's bytes are not the file's, so emitting one would cache a
|
|
168
168
|
// derivation under the source's identity.
|
|
169
|
-
if (
|
|
170
|
-
emitSqliteTwin(graph,
|
|
169
|
+
if (!twinCurrent && !foreignAdapted) {
|
|
170
|
+
emitSqliteTwin(graph, graphPath);
|
|
171
171
|
}
|
|
172
172
|
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
173
173
|
const outM = new Map<string, CodeEdge[]>();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `agent-config` a background refresh may start — shared by the
|
|
3
|
+
* `code-graph-refresh` session concern and `code-graph auto-refresh on`, so the
|
|
4
|
+
* opt-in can say at once when nothing usable is installed.
|
|
5
|
+
*
|
|
6
|
+
* The executable is never taken from the repository (its root, its
|
|
7
|
+
* `node_modules/.bin`, a relative PATH entry): the same trust rule the host hook
|
|
8
|
+
* shim applies before it runs the dispatcher. And it must be able to do the job:
|
|
9
|
+
* a global install older than the single-flight refresh ignores the flags it
|
|
10
|
+
* does not know and would build unlocked with an old extractor.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** A file only a package that implements `refresh --single-flight` ships (the published tarball carries `src/scripts/`). */
|
|
16
|
+
const CAPABILITY_FILE = 'src/scripts/code_graph/refresh_lock.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The first `agent-config` on PATH whose real location lies outside `root`.
|
|
20
|
+
* Relative PATH entries are skipped: they resolve against the repository.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveCli(root: string, envPath: string = process.env['PATH'] ?? ''): string | null {
|
|
23
|
+
let realRoot: string;
|
|
24
|
+
try {
|
|
25
|
+
realRoot = fs.realpathSync(root);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const inside = (p: string): boolean => {
|
|
30
|
+
const rel = path.relative(realRoot, p);
|
|
31
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
32
|
+
};
|
|
33
|
+
for (const dir of envPath.split(path.delimiter)) {
|
|
34
|
+
if (!path.isAbsolute(dir)) continue;
|
|
35
|
+
const candidate = path.join(dir, 'agent-config');
|
|
36
|
+
try {
|
|
37
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
38
|
+
const real = fs.realpathSync(candidate);
|
|
39
|
+
if (fs.statSync(real).isFile() && !inside(real) && !inside(path.resolve(dir))) return candidate;
|
|
40
|
+
} catch {
|
|
41
|
+
/* not here */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The installed package a CLI belongs to: the nearest `package.json` above its real path naming this package. */
|
|
48
|
+
export function cliPackage(cli: string): { root: string; version: string } | null {
|
|
49
|
+
try {
|
|
50
|
+
for (let dir = path.dirname(fs.realpathSync(cli)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
|
|
51
|
+
const file = path.join(dir, 'package.json');
|
|
52
|
+
if (!fs.existsSync(file)) continue;
|
|
53
|
+
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8')) as { name?: string; version?: string };
|
|
54
|
+
if (pkg.name === '@event4u/agent-config') return { root: dir, version: pkg.version ?? '?' };
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
/* unreadable — no package */
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the package a CLI belongs to implements the single-flight refresh.
|
|
64
|
+
* An installed CLI is `<package>/dist/cli/agent-config.js`; the check is the
|
|
65
|
+
* presence of a file only such a package ships, so nothing is executed.
|
|
66
|
+
*/
|
|
67
|
+
export function cliIsCapable(cli: string): boolean {
|
|
68
|
+
// The same package root the version report uses, so a symlinked or hoisted
|
|
69
|
+
// layout cannot make one call see a package the other does not.
|
|
70
|
+
const pkg = cliPackage(cli);
|
|
71
|
+
return pkg !== null && fs.existsSync(path.join(pkg.root, CAPABILITY_FILE));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type CliVerdict = { cli: string } | { reason: 'no-cli' } | { reason: 'cli-too-old'; cli: string };
|
|
75
|
+
|
|
76
|
+
/** A trusted `agent-config` that implements the single-flight refresh, or why there is none. */
|
|
77
|
+
export function usableCli(root: string, envPath?: string): CliVerdict {
|
|
78
|
+
const cli = resolveCli(root, envPath);
|
|
79
|
+
if (cli === null) return { reason: 'no-cli' };
|
|
80
|
+
return cliIsCapable(cli) ? { cli } : { reason: 'cli-too-old', cli };
|
|
81
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-flight lock for `code-graph refresh --single-flight` and `map`.
|
|
3
|
+
*
|
|
4
|
+
* Every producer goes through {@link acquire}: the git-hook refresh block in
|
|
5
|
+
* `install-hooks.sh` and the `code-graph-refresh` session concern both run
|
|
6
|
+
* `refresh --single-flight`, so there is one locking protocol, not two.
|
|
7
|
+
* `mkdir` is the primitive because it is atomic on every POSIX filesystem.
|
|
8
|
+
*
|
|
9
|
+
* Ownership is recorded in a SIBLING file, never inside the directory, so the
|
|
10
|
+
* directory is empty and a single `rmdir`-style removal clears it.
|
|
11
|
+
*
|
|
12
|
+
* A lock is reclaimed only on positive evidence that its owner is gone (see
|
|
13
|
+
* {@link isStale}); an owner-less lock — a torn owner write — counts as stale
|
|
14
|
+
* only past {@link STALE_LOCK_MS}. A release removes the lock only while the
|
|
15
|
+
* owner record still carries the releaser's nonce, so a holder that was
|
|
16
|
+
* reclaimed cannot delete its successor's lock on the way out.
|
|
17
|
+
*/
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as os from 'node:os';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { graphHome } from './graph_home.js';
|
|
24
|
+
|
|
25
|
+
export const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
26
|
+
/**
|
|
27
|
+
* How long an owner on ANOTHER host (a container sharing the checkout) is
|
|
28
|
+
* believed: this host cannot probe its PID, so age is the only signal. An
|
|
29
|
+
* availability compromise, not proof — it reads the owner's own wall clock, so
|
|
30
|
+
* clock skew between hosts shifts it. A same-host owner whose PID is alive is
|
|
31
|
+
* never reclaimed on age (ratification, 2026-10-24, two of two seats): a paused,
|
|
32
|
+
* debugged or overloaded refresh is still a refresh.
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_LOCK_AGE_MS = 30 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
interface Owner {
|
|
37
|
+
pid: number;
|
|
38
|
+
started_at: number;
|
|
39
|
+
nonce: string;
|
|
40
|
+
/** Absent in records written before the host was stored; such an owner is judged by age alone. */
|
|
41
|
+
host?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ownerPath = (lock: string): string => `${lock}.owner.json`;
|
|
45
|
+
|
|
46
|
+
export interface RefreshRecord {
|
|
47
|
+
at: string;
|
|
48
|
+
/** `session-start` (the concern) or `refresh` (the CLI run it starts, or a manual one). */
|
|
49
|
+
by: 'session-start' | 'refresh';
|
|
50
|
+
outcome: string;
|
|
51
|
+
detail?: string | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One file per side, so the session concern writes without reading or merging anything. */
|
|
55
|
+
export const refreshRecordPath = (root: string, by: RefreshRecord['by']): string =>
|
|
56
|
+
path.join(path.dirname(graphHome(root).lockPath), `code-graph-refresh.${by}.json`);
|
|
57
|
+
|
|
58
|
+
/** The last outcome of one side, so a refresh that never happens is visible. Best-effort. */
|
|
59
|
+
export function recordRefresh(root: string, rec: Omit<RefreshRecord, 'at'>): void {
|
|
60
|
+
recordRefreshIn(path.dirname(graphHome(root).lockPath), rec);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** For the session concern: its chunk must not carry the user-global resolver. */
|
|
64
|
+
export function recordRefreshIn(stateDir: string, rec: Omit<RefreshRecord, 'at'>): void {
|
|
65
|
+
try {
|
|
66
|
+
fs.writeFileSync(path.join(stateDir, `code-graph-refresh.${rec.by}.json`), JSON.stringify({ at: new Date().toISOString(), ...rec }));
|
|
67
|
+
} catch {
|
|
68
|
+
/* never fails a refresh */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readRefreshRecords(root: string): Partial<Record<RefreshRecord['by'], RefreshRecord>> {
|
|
73
|
+
const out: Partial<Record<RefreshRecord['by'], RefreshRecord>> = {};
|
|
74
|
+
for (const by of ['session-start', 'refresh'] as const) {
|
|
75
|
+
try {
|
|
76
|
+
out[by] = JSON.parse(fs.readFileSync(refreshRecordPath(root, by), 'utf-8')) as RefreshRecord;
|
|
77
|
+
} catch {
|
|
78
|
+
/* never recorded */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function pidAlive(pid: number): boolean {
|
|
85
|
+
try {
|
|
86
|
+
process.kill(pid, 0);
|
|
87
|
+
return true;
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// EPERM means the process exists but belongs to someone else.
|
|
90
|
+
return (e as NodeJS.ErrnoException).code === 'EPERM';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const errno = (e: unknown): string | undefined => (e as NodeJS.ErrnoException).code;
|
|
95
|
+
|
|
96
|
+
/** The owner record; `'none'` when there is no file or no valid record; `'unreadable'` on any other error. */
|
|
97
|
+
function readOwner(lock: string): Owner | 'none' | 'unreadable' {
|
|
98
|
+
let raw: string;
|
|
99
|
+
try {
|
|
100
|
+
raw = fs.readFileSync(ownerPath(lock), 'utf-8');
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return errno(e) === 'ENOENT' ? 'none' : 'unreadable';
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const o = JSON.parse(raw) as Partial<Owner>;
|
|
106
|
+
if (typeof o.pid === 'number' && typeof o.started_at === 'number' && typeof o.nonce === 'string') return o as Owner;
|
|
107
|
+
} catch {
|
|
108
|
+
/* a torn write; fall through to the age rule */
|
|
109
|
+
}
|
|
110
|
+
return 'none';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* True only on positive evidence: the owner's process is gone, the lock has
|
|
115
|
+
* vanished, or an owner-less lock is provably older than the threshold. A lock
|
|
116
|
+
* or owner record that cannot be inspected is treated as live.
|
|
117
|
+
*/
|
|
118
|
+
export function isStale(lock: string, now: number = Date.now()): boolean {
|
|
119
|
+
const owner = readOwner(lock);
|
|
120
|
+
if (owner === 'unreadable') return false;
|
|
121
|
+
if (owner !== 'none') {
|
|
122
|
+
// Only an owner that NAMES this host is judged by its PID. Another host's
|
|
123
|
+
// PID means nothing here, and a record without a host cannot be placed,
|
|
124
|
+
// so both are judged by age alone (see MAX_LOCK_AGE_MS).
|
|
125
|
+
if (owner.host !== os.hostname()) return now - owner.started_at > MAX_LOCK_AGE_MS;
|
|
126
|
+
return !pidAlive(owner.pid);
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
return now - fs.statSync(lock).mtimeMs > STALE_LOCK_MS;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
return errno(e) === 'ENOENT';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function take(lock: string): () => void {
|
|
136
|
+
const nonce = randomBytes(16).toString('hex');
|
|
137
|
+
fs.writeFileSync(ownerPath(lock), JSON.stringify({ pid: process.pid, started_at: Date.now(), nonce, host: os.hostname() }));
|
|
138
|
+
return () => {
|
|
139
|
+
const owner = readOwner(lock);
|
|
140
|
+
if (typeof owner === 'string' || owner.nonce !== nonce) return;
|
|
141
|
+
fs.rmSync(ownerPath(lock), { force: true });
|
|
142
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export const reclaimMutexPath = (lock: string): string => `${lock}.reclaim`;
|
|
147
|
+
|
|
148
|
+
/** True when this process holds the lock (the CLI took it before calling in). */
|
|
149
|
+
export function heldByThisProcess(root: string): boolean {
|
|
150
|
+
const owner = readOwner(graphHome(root).lockPath);
|
|
151
|
+
return typeof owner !== 'string' && owner.pid === process.pid;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Take the lock, or return null when another refresh holds it.
|
|
156
|
+
*
|
|
157
|
+
* A stale lock is removed only while holding a second mkdir mutex, and only
|
|
158
|
+
* after staleness is checked AGAIN under it. A reclaimer that judged the lock
|
|
159
|
+
* stale and was then overtaken finds the successor's live lock on that second
|
|
160
|
+
* check and declines, so it can never delete a lock created after it looked.
|
|
161
|
+
* A reclaim mutex left by a crash inside that short section is never removed
|
|
162
|
+
* automatically: no check before a removal can prove which mutex instance it
|
|
163
|
+
* removes, and removing a successor's would let two reclaimers in (ratification,
|
|
164
|
+
* 2026-10-24). Stale locks then stay held — lost freshness, never lost exclusion
|
|
165
|
+
* — and `code-graph auto-refresh status` names the leftover for a human to remove.
|
|
166
|
+
* `afterStaleCheck` exists for the interleaving test only.
|
|
167
|
+
*/
|
|
168
|
+
export function acquire(root: string, afterStaleCheck?: () => void): (() => void) | null {
|
|
169
|
+
const lock = graphHome(root).lockPath;
|
|
170
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
171
|
+
try {
|
|
172
|
+
fs.mkdirSync(lock);
|
|
173
|
+
return take(lock);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (errno(e) !== 'EEXIST' || !isStale(lock)) return null;
|
|
176
|
+
}
|
|
177
|
+
afterStaleCheck?.();
|
|
178
|
+
const mutex = reclaimMutexPath(lock);
|
|
179
|
+
try {
|
|
180
|
+
fs.mkdirSync(mutex);
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
if (!isStale(lock)) return null;
|
|
186
|
+
fs.rmSync(ownerPath(lock), { force: true });
|
|
187
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
188
|
+
try {
|
|
189
|
+
fs.mkdirSync(lock);
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
return take(lock);
|
|
194
|
+
} finally {
|
|
195
|
+
fs.rmSync(mutex, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
}
|