gitnexus 1.6.6-rc.130 → 1.6.6-rc.131
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/ai-context.d.ts +34 -1
- package/dist/cli/ai-context.js +75 -3
- package/dist/cli/analyze-config.d.ts +92 -0
- package/dist/cli/analyze-config.js +364 -0
- package/dist/cli/analyze.d.ts +7 -0
- package/dist/cli/analyze.js +141 -56
- package/dist/cli/cli-message.d.ts +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/core/run-analyze.d.ts +7 -0
- package/dist/core/run-analyze.js +1 -0
- package/dist/storage/git.d.ts +11 -0
- package/dist/storage/git.js +30 -0
- package/package.json +1 -1
package/dist/cli/ai-context.d.ts
CHANGED
|
@@ -18,12 +18,45 @@ export interface AIContextOptions {
|
|
|
18
18
|
skipAgentsMd?: boolean;
|
|
19
19
|
noStats?: boolean;
|
|
20
20
|
skipSkills?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Default branch used by the generated regression-compare example (#243).
|
|
23
|
+
* Resolved by the CLI (CLI flag > `.gitnexusrc` > auto-detect > "main"); a
|
|
24
|
+
* plain caller that omits it gets "main", preserving prior behavior.
|
|
25
|
+
*/
|
|
26
|
+
defaultBranch?: string;
|
|
21
27
|
}
|
|
22
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Strip backticks from a branch name before it is embedded in a Markdown
|
|
30
|
+
* inline-code span (#1996 tri-review P1). validateBranchName already rejects
|
|
31
|
+
* backticks for CLI/config/auto-detect inputs; this is the last-line defense at
|
|
32
|
+
* the generation sink so the embedding is provably safe regardless of caller.
|
|
33
|
+
*/
|
|
34
|
+
export declare function markdownSafeBranch(branch: string): string;
|
|
35
|
+
export declare function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], groupNames?: string[], noStats?: boolean, skipSkills?: boolean, runnerPath?: string, defaultBranch?: string): string;
|
|
23
36
|
/**
|
|
24
37
|
* Generate AI context files after indexing
|
|
25
38
|
*/
|
|
26
39
|
export declare function generateAIContextFiles(repoPath: string, storagePath: string, projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[], options?: AIContextOptions): Promise<{
|
|
27
40
|
files: string[];
|
|
28
41
|
}>;
|
|
42
|
+
/**
|
|
43
|
+
* Refresh only the `base_ref: "..."` value inside the GitNexus block of an
|
|
44
|
+
* already-generated AGENTS.md / CLAUDE.md, in place (#1996 tri-review P2).
|
|
45
|
+
*
|
|
46
|
+
* The `alreadyUpToDate` analyze fast path returns before the normal
|
|
47
|
+
* {@link generateAIContextFiles} call, so a changed `.gitnexusrc` defaultBranch
|
|
48
|
+
* (or `--default-branch`) would otherwise not take effect until the next
|
|
49
|
+
* re-index. This does a surgical line update that preserves the rest of the
|
|
50
|
+
* block — including community-skill rows written by a prior `--skills` run —
|
|
51
|
+
* rather than regenerating (which would drop those rows on a no-`--skills` run).
|
|
52
|
+
*
|
|
53
|
+
* Best-effort: missing files, a missing/blank block, or a block with no
|
|
54
|
+
* `base_ref` line (e.g. a user-trimmed keep block) are silently skipped. Writes
|
|
55
|
+
* only when the value actually changes, so a routine up-to-date run is a no-op.
|
|
56
|
+
*/
|
|
57
|
+
export declare function refreshBaseRefLine(repoPath: string, defaultBranch: string, options?: {
|
|
58
|
+
skipAgentsMd?: boolean;
|
|
59
|
+
}): Promise<{
|
|
60
|
+
files: string[];
|
|
61
|
+
}>;
|
|
29
62
|
export {};
|
package/dist/cli/ai-context.js
CHANGED
|
@@ -68,11 +68,28 @@ async function findGroupsContainingRegistryName(registryName) {
|
|
|
68
68
|
}
|
|
69
69
|
return hits;
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Strip backticks from a branch name before it is embedded in a Markdown
|
|
73
|
+
* inline-code span (#1996 tri-review P1). validateBranchName already rejects
|
|
74
|
+
* backticks for CLI/config/auto-detect inputs; this is the last-line defense at
|
|
75
|
+
* the generation sink so the embedding is provably safe regardless of caller.
|
|
76
|
+
*/
|
|
77
|
+
export function markdownSafeBranch(branch) {
|
|
78
|
+
return branch.replace(/`/g, '');
|
|
79
|
+
}
|
|
71
80
|
export function generateGitNexusContent(projectName, stats, generatedSkills, groupNames, noStats, skipSkills,
|
|
72
81
|
// Project-relative path to the runner `gitnexus analyze` drops next to the
|
|
73
82
|
// index (#1945). Referenced by docs so a single CLI-neutral command resolves
|
|
74
83
|
// the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time.
|
|
75
|
-
runnerPath = '.gitnexus/run.cjs'
|
|
84
|
+
runnerPath = '.gitnexus/run.cjs',
|
|
85
|
+
// Default branch for the regression-compare example (#243). Configurable so
|
|
86
|
+
// projects on `develop`/`master`/etc. don't get `base_ref: "main"` rewritten
|
|
87
|
+
// back over their fix on every analyze. The value is embedded inside a
|
|
88
|
+
// Markdown inline-code span: validateBranchName rejects backticks upstream,
|
|
89
|
+
// and `markdownSafeBranch` strips any remaining backtick here as defense in
|
|
90
|
+
// depth, so JSON.stringify's quote/escape handling is sufficient and the
|
|
91
|
+
// branch cannot break out of the span (#1996 tri-review P1).
|
|
92
|
+
defaultBranch = 'main') {
|
|
76
93
|
const generatedRows = generatedSkills && generatedSkills.length > 0
|
|
77
94
|
? generatedSkills
|
|
78
95
|
.map((s) => `| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`)
|
|
@@ -115,7 +132,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
|
|
|
115
132
|
## Always Do
|
|
116
133
|
|
|
117
134
|
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run \`gitnexus_impact({target: "symbolName", direction: "upstream"})\` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
|
118
|
-
- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows.
|
|
135
|
+
- **MUST run \`gitnexus_detect_changes()\` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: \`gitnexus_detect_changes({scope: "compare", base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}})\`.
|
|
119
136
|
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
|
120
137
|
- When exploring unfamiliar code, use \`gitnexus_query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
|
121
138
|
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`gitnexus_context({name: "symbolName"})\`.
|
|
@@ -331,7 +348,7 @@ export async function generateAIContextFiles(repoPath, storagePath, projectName,
|
|
|
331
348
|
catch (err) {
|
|
332
349
|
logger.warn(`Could not write GitNexus runner to ${runnerPath}: ${String(err)}`);
|
|
333
350
|
}
|
|
334
|
-
const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames, options?.noStats, options?.skipSkills, runnerPath);
|
|
351
|
+
const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames, options?.noStats, options?.skipSkills, runnerPath, options?.defaultBranch ?? 'main');
|
|
335
352
|
const createdFiles = [];
|
|
336
353
|
if (!options?.skipAgentsMd) {
|
|
337
354
|
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
|
|
@@ -359,3 +376,58 @@ export async function generateAIContextFiles(repoPath, storagePath, projectName,
|
|
|
359
376
|
}
|
|
360
377
|
return { files: createdFiles };
|
|
361
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Refresh only the `base_ref: "..."` value inside the GitNexus block of an
|
|
381
|
+
* already-generated AGENTS.md / CLAUDE.md, in place (#1996 tri-review P2).
|
|
382
|
+
*
|
|
383
|
+
* The `alreadyUpToDate` analyze fast path returns before the normal
|
|
384
|
+
* {@link generateAIContextFiles} call, so a changed `.gitnexusrc` defaultBranch
|
|
385
|
+
* (or `--default-branch`) would otherwise not take effect until the next
|
|
386
|
+
* re-index. This does a surgical line update that preserves the rest of the
|
|
387
|
+
* block — including community-skill rows written by a prior `--skills` run —
|
|
388
|
+
* rather than regenerating (which would drop those rows on a no-`--skills` run).
|
|
389
|
+
*
|
|
390
|
+
* Best-effort: missing files, a missing/blank block, or a block with no
|
|
391
|
+
* `base_ref` line (e.g. a user-trimmed keep block) are silently skipped. Writes
|
|
392
|
+
* only when the value actually changes, so a routine up-to-date run is a no-op.
|
|
393
|
+
*/
|
|
394
|
+
export async function refreshBaseRefLine(repoPath, defaultBranch, options) {
|
|
395
|
+
if (options?.skipAgentsMd)
|
|
396
|
+
return { files: [] };
|
|
397
|
+
const replacement = `base_ref: ${JSON.stringify(markdownSafeBranch(defaultBranch))}`;
|
|
398
|
+
const updated = [];
|
|
399
|
+
for (const name of ['AGENTS.md', 'CLAUDE.md']) {
|
|
400
|
+
const filePath = path.join(repoPath, name);
|
|
401
|
+
if (!(await fileExists(filePath)))
|
|
402
|
+
continue;
|
|
403
|
+
let content;
|
|
404
|
+
try {
|
|
405
|
+
content = await fs.readFile(filePath, 'utf-8');
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const startIdx = findSectionMarkerIndex(content, GITNEXUS_START_MARKER);
|
|
411
|
+
if (startIdx === -1)
|
|
412
|
+
continue;
|
|
413
|
+
const endIdx = findSectionMarkerIndex(content, GITNEXUS_END_MARKER, startIdx);
|
|
414
|
+
if (endIdx === -1 || endIdx <= startIdx)
|
|
415
|
+
continue;
|
|
416
|
+
const blockEnd = endIdx + GITNEXUS_END_MARKER.length;
|
|
417
|
+
const block = content.substring(startIdx, blockEnd);
|
|
418
|
+
// Only the generated regression example carries a base_ref line, and only
|
|
419
|
+
// one per block; replace its quoted value while leaving the rest untouched.
|
|
420
|
+
const newBlock = block.replace(/base_ref: "(?:[^"\\]|\\.)*"/, replacement);
|
|
421
|
+
if (newBlock === block)
|
|
422
|
+
continue; // no base_ref line present, or already current
|
|
423
|
+
const newContent = content.substring(0, startIdx) + newBlock + content.substring(blockEnd);
|
|
424
|
+
try {
|
|
425
|
+
await fs.writeFile(filePath, newContent, 'utf-8');
|
|
426
|
+
updated.push(name);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// best-effort — never fail analyze over a context refresh
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return { files: updated };
|
|
433
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-local `.gitnexusrc` support for `gitnexus analyze` (#243).
|
|
3
|
+
*
|
|
4
|
+
* Lets a repository commit recurring `analyze` defaults — default branch for the
|
|
5
|
+
* generated regression example, AI-context / skills opt-outs, embedding knobs —
|
|
6
|
+
* so contributors don't re-pass the same flags on every run. Design rules:
|
|
7
|
+
*
|
|
8
|
+
* - Config is repo-local (`.gitnexusrc` at the resolved repo root). It is NOT
|
|
9
|
+
* read from `.gitnexus/` because that directory is index storage and is
|
|
10
|
+
* commonly gitignored.
|
|
11
|
+
* - JSON only. No YAML, no `package.json` field, no global `~/.gitnexus` file
|
|
12
|
+
* in this pass.
|
|
13
|
+
* - CLI flags always override config (see {@link mergeAnalyzeOptions}).
|
|
14
|
+
* - Fail closed: unknown keys, wrong value types, conflicting aliases, and
|
|
15
|
+
* invalid JSON all throw {@link GitNexusRcError} so a typo never silently
|
|
16
|
+
* no-ops. Errors are actionable and surface before any expensive analysis.
|
|
17
|
+
* - Config values never reach a shell. Strings are validated against control
|
|
18
|
+
* and hidden/bidirectional characters so they cannot inject markdown,
|
|
19
|
+
* JSON, or hidden controls into generated context files.
|
|
20
|
+
*
|
|
21
|
+
* Both a flat shape and a nested `analyze` block are accepted:
|
|
22
|
+
*
|
|
23
|
+
* { "defaultBranch": "develop", "skipContextFiles": true }
|
|
24
|
+
* { "analyze": { "defaultBranch": "develop", "skipSkills": true } }
|
|
25
|
+
*
|
|
26
|
+
* When both set the same option, the nested `analyze` block wins (deterministic,
|
|
27
|
+
* documented precedence). Conflicting *aliases at the same level* (e.g. both
|
|
28
|
+
* `defaultBranch` and `branch`) are rejected.
|
|
29
|
+
*/
|
|
30
|
+
import type { AnalyzeOptions } from './analyze.js';
|
|
31
|
+
export declare const GITNEXUS_RC_FILENAME = ".gitnexusrc";
|
|
32
|
+
/** Final fallback when no branch is configured or detectable. */
|
|
33
|
+
export declare const DEFAULT_BRANCH_FALLBACK = "main";
|
|
34
|
+
/**
|
|
35
|
+
* Thrown for any `.gitnexusrc` problem (missing-file is NOT an error — it
|
|
36
|
+
* returns `undefined`). The message is user-facing and names the file so the
|
|
37
|
+
* CLI can print it verbatim before starting the progress bar.
|
|
38
|
+
*/
|
|
39
|
+
export declare class GitNexusRcError extends Error {
|
|
40
|
+
constructor(message: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
|
|
44
|
+
* trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
|
|
45
|
+
* shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
|
|
46
|
+
*/
|
|
47
|
+
export declare function validateBranchName(value: string, source: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* Best-effort validation for an auto-detected branch (from git). Never throws —
|
|
50
|
+
* returns `undefined` for anything unusable so the resolver falls back to the
|
|
51
|
+
* next precedence tier.
|
|
52
|
+
*/
|
|
53
|
+
export declare function sanitizeDetectedBranch(value: string | null | undefined): string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Locate, read, parse, validate, and normalize `.gitnexusrc` at `repoRoot`.
|
|
56
|
+
*
|
|
57
|
+
* @returns the normalized config defaults, or `undefined` when no file exists
|
|
58
|
+
* (the normal case). Throws {@link GitNexusRcError} on any problem.
|
|
59
|
+
*/
|
|
60
|
+
export declare function loadAnalyzeConfig(repoRoot: string): Partial<AnalyzeOptions> | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Merge CLI options over `.gitnexusrc` defaults. CLI wins whenever it provides a
|
|
63
|
+
* value — including an explicit `false` (so an explicit CLI off-switch beats a
|
|
64
|
+
* config `true`). Config fills only the genuinely-unset (`undefined`) options.
|
|
65
|
+
*
|
|
66
|
+
* `stats` is special: Commander always materializes it (`true` by default,
|
|
67
|
+
* `false` only when `--no-stats` is passed), so plain `??` can't tell "default
|
|
68
|
+
* on" from "explicitly on". The rule is: a passed `--no-stats` (`stats: false`)
|
|
69
|
+
* always wins; otherwise the config value applies. There is intentionally no
|
|
70
|
+
* `--stats` counter-flag, so config can only turn stats off, not force it back
|
|
71
|
+
* on against a `--no-stats`.
|
|
72
|
+
*
|
|
73
|
+
* `defaultBranch` is NOT resolved here — see {@link resolveDefaultBranch}, which
|
|
74
|
+
* applies the CLI > config > auto-detect > "main" precedence chain.
|
|
75
|
+
*/
|
|
76
|
+
export declare function mergeAnalyzeOptions(cli: AnalyzeOptions, config: Partial<AnalyzeOptions> | undefined): AnalyzeOptions;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the default branch threaded into generated context, applying the
|
|
79
|
+
* precedence chain:
|
|
80
|
+
*
|
|
81
|
+
* CLI `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch`
|
|
82
|
+
* > auto-detected `origin/HEAD` > {@link DEFAULT_BRANCH_FALLBACK} ("main").
|
|
83
|
+
*
|
|
84
|
+
* User-supplied values (CLI, config) are validated strictly and throw on bad
|
|
85
|
+
* input. The auto-detected value is best-effort and silently ignored if
|
|
86
|
+
* unusable.
|
|
87
|
+
*/
|
|
88
|
+
export declare function resolveDefaultBranch(input: {
|
|
89
|
+
cliBranch?: string;
|
|
90
|
+
configBranch?: string;
|
|
91
|
+
detectedBranch?: string | null;
|
|
92
|
+
}): string;
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project-local `.gitnexusrc` support for `gitnexus analyze` (#243).
|
|
3
|
+
*
|
|
4
|
+
* Lets a repository commit recurring `analyze` defaults — default branch for the
|
|
5
|
+
* generated regression example, AI-context / skills opt-outs, embedding knobs —
|
|
6
|
+
* so contributors don't re-pass the same flags on every run. Design rules:
|
|
7
|
+
*
|
|
8
|
+
* - Config is repo-local (`.gitnexusrc` at the resolved repo root). It is NOT
|
|
9
|
+
* read from `.gitnexus/` because that directory is index storage and is
|
|
10
|
+
* commonly gitignored.
|
|
11
|
+
* - JSON only. No YAML, no `package.json` field, no global `~/.gitnexus` file
|
|
12
|
+
* in this pass.
|
|
13
|
+
* - CLI flags always override config (see {@link mergeAnalyzeOptions}).
|
|
14
|
+
* - Fail closed: unknown keys, wrong value types, conflicting aliases, and
|
|
15
|
+
* invalid JSON all throw {@link GitNexusRcError} so a typo never silently
|
|
16
|
+
* no-ops. Errors are actionable and surface before any expensive analysis.
|
|
17
|
+
* - Config values never reach a shell. Strings are validated against control
|
|
18
|
+
* and hidden/bidirectional characters so they cannot inject markdown,
|
|
19
|
+
* JSON, or hidden controls into generated context files.
|
|
20
|
+
*
|
|
21
|
+
* Both a flat shape and a nested `analyze` block are accepted:
|
|
22
|
+
*
|
|
23
|
+
* { "defaultBranch": "develop", "skipContextFiles": true }
|
|
24
|
+
* { "analyze": { "defaultBranch": "develop", "skipSkills": true } }
|
|
25
|
+
*
|
|
26
|
+
* When both set the same option, the nested `analyze` block wins (deterministic,
|
|
27
|
+
* documented precedence). Conflicting *aliases at the same level* (e.g. both
|
|
28
|
+
* `defaultBranch` and `branch`) are rejected.
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
export const GITNEXUS_RC_FILENAME = '.gitnexusrc';
|
|
33
|
+
/** Final fallback when no branch is configured or detectable. */
|
|
34
|
+
export const DEFAULT_BRANCH_FALLBACK = 'main';
|
|
35
|
+
/** Git refs longer than this are almost certainly a mistake / injection attempt. */
|
|
36
|
+
const BRANCH_MAX_LENGTH = 255;
|
|
37
|
+
/**
|
|
38
|
+
* Thrown for any `.gitnexusrc` problem (missing-file is NOT an error — it
|
|
39
|
+
* returns `undefined`). The message is user-facing and names the file so the
|
|
40
|
+
* CLI can print it verbatim before starting the progress bar.
|
|
41
|
+
*/
|
|
42
|
+
export class GitNexusRcError extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'GitNexusRcError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Allowed `.gitnexusrc` keys and how each maps onto `AnalyzeOptions`.
|
|
50
|
+
*
|
|
51
|
+
* Aliases intentionally collapse onto a shared target:
|
|
52
|
+
* - `branch` is the legacy alias for `defaultBranch` (issue-comment shape).
|
|
53
|
+
* - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they
|
|
54
|
+
* suppress the AGENTS.md / CLAUDE.md block ONLY. They do not imply
|
|
55
|
+
* `skipSkills`, and they are weaker than `indexOnly` (which skips all
|
|
56
|
+
* file injection). This matches the existing CLI semantics exactly.
|
|
57
|
+
* - `noStats` is the negation of `stats`.
|
|
58
|
+
*/
|
|
59
|
+
const KEY_SPECS = {
|
|
60
|
+
defaultBranch: { target: 'defaultBranch', kind: 'branch' },
|
|
61
|
+
branch: { target: 'defaultBranch', kind: 'branch' },
|
|
62
|
+
skipAgentsMd: { target: 'skipAgentsMd', kind: 'boolean' },
|
|
63
|
+
skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' },
|
|
64
|
+
skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' },
|
|
65
|
+
skipSkills: { target: 'skipSkills', kind: 'boolean' },
|
|
66
|
+
indexOnly: { target: 'indexOnly', kind: 'boolean' },
|
|
67
|
+
stats: { target: 'stats', kind: 'boolean' },
|
|
68
|
+
noStats: { target: 'stats', kind: 'boolean-negate' },
|
|
69
|
+
embeddings: { target: 'embeddings', kind: 'embeddings' },
|
|
70
|
+
dropEmbeddings: { target: 'dropEmbeddings', kind: 'boolean' },
|
|
71
|
+
name: { target: 'name', kind: 'string' },
|
|
72
|
+
allowDuplicateName: { target: 'allowDuplicateName', kind: 'boolean' },
|
|
73
|
+
maxFileSize: { target: 'maxFileSize', kind: 'numeric-string' },
|
|
74
|
+
workerTimeout: { target: 'workerTimeout', kind: 'numeric-string' },
|
|
75
|
+
walCheckpointThreshold: { target: 'walCheckpointThreshold', kind: 'numeric-string' },
|
|
76
|
+
workers: { target: 'workers', kind: 'numeric-string' },
|
|
77
|
+
embeddingThreads: { target: 'embeddingThreads', kind: 'numeric-string' },
|
|
78
|
+
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
|
|
79
|
+
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
|
|
80
|
+
embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
|
|
81
|
+
};
|
|
82
|
+
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
|
|
83
|
+
const NESTED_KEY = 'analyze';
|
|
84
|
+
const ALLOWED_KEYS_HINT = `Allowed keys: ${Object.keys(KEY_SPECS).join(', ')} (or a nested "${NESTED_KEY}" object).`;
|
|
85
|
+
/**
|
|
86
|
+
* Reject control characters and hidden / bidirectional Unicode in a string
|
|
87
|
+
* value. These have no legitimate place in a branch name, registry name, or
|
|
88
|
+
* device string, and would otherwise let a committed config smuggle invisible
|
|
89
|
+
* controls into generated AGENTS.md / CLAUDE.md content.
|
|
90
|
+
*/
|
|
91
|
+
const isHiddenOrControl = (codePoint) => codePoint < 0x20 ||
|
|
92
|
+
codePoint === 0x7f ||
|
|
93
|
+
(codePoint >= 0x200b && codePoint <= 0x200f) || // zero-width + LRM/RLM
|
|
94
|
+
(codePoint >= 0x202a && codePoint <= 0x202e) || // bidi embeddings/overrides
|
|
95
|
+
(codePoint >= 0x2060 && codePoint <= 0x2064) || // word-joiner + invisible math
|
|
96
|
+
(codePoint >= 0x2066 && codePoint <= 0x206f) || // bidi isolates + deprecated
|
|
97
|
+
codePoint === 0xfeff; // BOM / zero-width no-break space
|
|
98
|
+
const assertNoHiddenChars = (value, source) => {
|
|
99
|
+
for (const ch of value) {
|
|
100
|
+
const cp = ch.codePointAt(0);
|
|
101
|
+
if (cp !== undefined && isHiddenOrControl(cp)) {
|
|
102
|
+
throw new GitNexusRcError(`${source}: value contains control or hidden/bidirectional characters, which are not allowed.`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Validate a user-supplied branch name (from CLI or `.gitnexusrc`). Returns the
|
|
108
|
+
* trimmed name or throws {@link GitNexusRcError}. Conservative but accepts the
|
|
109
|
+
* shapes real branches use (`feature/foo-bar`, `release/1.2`, `develop`).
|
|
110
|
+
*/
|
|
111
|
+
export function validateBranchName(value, source) {
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
if (!trimmed) {
|
|
114
|
+
throw new GitNexusRcError(`${source}: branch name must not be empty.`);
|
|
115
|
+
}
|
|
116
|
+
if (trimmed.length > BRANCH_MAX_LENGTH) {
|
|
117
|
+
throw new GitNexusRcError(`${source}: branch name is too long (max ${BRANCH_MAX_LENGTH}).`);
|
|
118
|
+
}
|
|
119
|
+
assertNoHiddenChars(trimmed, source);
|
|
120
|
+
if (/\s/.test(trimmed)) {
|
|
121
|
+
throw new GitNexusRcError(`${source}: branch name must not contain whitespace.`);
|
|
122
|
+
}
|
|
123
|
+
// git ref-name rules (subset): reject characters git itself forbids in refs.
|
|
124
|
+
if (/[~^:?*[\\]/.test(trimmed)) {
|
|
125
|
+
throw new GitNexusRcError(`${source}: branch name contains characters not allowed in a git ref (~ ^ : ? * [ \\).`);
|
|
126
|
+
}
|
|
127
|
+
if (trimmed.startsWith('-')) {
|
|
128
|
+
throw new GitNexusRcError(`${source}: branch name must not start with "-".`);
|
|
129
|
+
}
|
|
130
|
+
if (trimmed.includes('..')) {
|
|
131
|
+
throw new GitNexusRcError(`${source}: branch name must not contain "..".`);
|
|
132
|
+
}
|
|
133
|
+
// Git permits a backtick in a ref, but the branch is embedded inside a
|
|
134
|
+
// Markdown inline-code span in the generated AGENTS.md/CLAUDE.md regression
|
|
135
|
+
// example, where a backtick would close the span early and let the rest of
|
|
136
|
+
// the template render as instruction text. Reject it at this single
|
|
137
|
+
// chokepoint so all three tiers (CLI flag, .gitnexusrc, auto-detect via
|
|
138
|
+
// sanitizeDetectedBranch) are covered (#1996 tri-review P1).
|
|
139
|
+
if (trimmed.includes('`')) {
|
|
140
|
+
throw new GitNexusRcError(`${source}: branch name must not contain a backtick (it would break the generated Markdown).`);
|
|
141
|
+
}
|
|
142
|
+
return trimmed;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Best-effort validation for an auto-detected branch (from git). Never throws —
|
|
146
|
+
* returns `undefined` for anything unusable so the resolver falls back to the
|
|
147
|
+
* next precedence tier.
|
|
148
|
+
*/
|
|
149
|
+
export function sanitizeDetectedBranch(value) {
|
|
150
|
+
if (!value)
|
|
151
|
+
return undefined;
|
|
152
|
+
try {
|
|
153
|
+
return validateBranchName(value, 'detected branch');
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const normalizeValue = (kind, value, key) => {
|
|
160
|
+
const source = `${GITNEXUS_RC_FILENAME} "${key}"`;
|
|
161
|
+
switch (kind) {
|
|
162
|
+
case 'boolean':
|
|
163
|
+
if (typeof value !== 'boolean') {
|
|
164
|
+
throw new GitNexusRcError(`${source} must be a boolean (true/false).`);
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
case 'boolean-negate':
|
|
168
|
+
if (typeof value !== 'boolean') {
|
|
169
|
+
throw new GitNexusRcError(`${source} must be a boolean (true/false).`);
|
|
170
|
+
}
|
|
171
|
+
return !value;
|
|
172
|
+
case 'branch':
|
|
173
|
+
if (typeof value !== 'string') {
|
|
174
|
+
throw new GitNexusRcError(`${source} must be a string branch name.`);
|
|
175
|
+
}
|
|
176
|
+
return validateBranchName(value, source);
|
|
177
|
+
case 'string': {
|
|
178
|
+
if (typeof value !== 'string') {
|
|
179
|
+
throw new GitNexusRcError(`${source} must be a string.`);
|
|
180
|
+
}
|
|
181
|
+
const trimmed = value.trim();
|
|
182
|
+
if (!trimmed) {
|
|
183
|
+
throw new GitNexusRcError(`${source} must not be empty.`);
|
|
184
|
+
}
|
|
185
|
+
assertNoHiddenChars(trimmed, source);
|
|
186
|
+
// `name` flows into the generated AGENTS.md/CLAUDE.md as `**${name}**` and
|
|
187
|
+
// inside `gitnexus://repo/${name}/…` code spans, so a Markdown-significant
|
|
188
|
+
// character would break those spans or inject emphasis/links/HTML into
|
|
189
|
+
// agent-instruction content (#1996 tri-review P1). `_` is intentionally
|
|
190
|
+
// allowed (legitimate in repo names; intraword `_` is not emphasis).
|
|
191
|
+
// embeddingDevice (the other `string`-kind option) only ever holds a
|
|
192
|
+
// fixed device token, so this guard never rejects a valid value there.
|
|
193
|
+
if (/[`*[\]<>]/.test(trimmed)) {
|
|
194
|
+
throw new GitNexusRcError(`${source} must not contain Markdown-significant characters (\` * [ ] < >).`);
|
|
195
|
+
}
|
|
196
|
+
return trimmed;
|
|
197
|
+
}
|
|
198
|
+
case 'numeric-string': {
|
|
199
|
+
// Mirror Commander's contract: these options reach the existing CLI
|
|
200
|
+
// validation as strings. Accept a JSON number or a string; normalize to a
|
|
201
|
+
// string and let the downstream per-flag validation enforce ranges so the
|
|
202
|
+
// error messages stay in one place.
|
|
203
|
+
if (typeof value === 'number') {
|
|
204
|
+
if (!Number.isFinite(value)) {
|
|
205
|
+
throw new GitNexusRcError(`${source} must be a finite number.`);
|
|
206
|
+
}
|
|
207
|
+
return String(value);
|
|
208
|
+
}
|
|
209
|
+
if (typeof value === 'string') {
|
|
210
|
+
const trimmed = value.trim();
|
|
211
|
+
if (!trimmed) {
|
|
212
|
+
throw new GitNexusRcError(`${source} must not be empty.`);
|
|
213
|
+
}
|
|
214
|
+
return trimmed;
|
|
215
|
+
}
|
|
216
|
+
throw new GitNexusRcError(`${source} must be a number or numeric string.`);
|
|
217
|
+
}
|
|
218
|
+
case 'embeddings': {
|
|
219
|
+
// Mirror `--embeddings [limit]`: boolean toggles, a non-negative integer
|
|
220
|
+
// sets the node cap (normalized to a string, as Commander would supply).
|
|
221
|
+
if (typeof value === 'boolean')
|
|
222
|
+
return value;
|
|
223
|
+
if (typeof value === 'number') {
|
|
224
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
225
|
+
throw new GitNexusRcError(`${source} must be true/false or a non-negative integer (node cap; 0 disables the cap).`);
|
|
226
|
+
}
|
|
227
|
+
return String(value);
|
|
228
|
+
}
|
|
229
|
+
throw new GitNexusRcError(`${source} must be a boolean or a non-negative integer (node cap; 0 disables the cap).`);
|
|
230
|
+
}
|
|
231
|
+
default:
|
|
232
|
+
// Exhaustive — kept for forward-compat if a new kind is added.
|
|
233
|
+
throw new GitNexusRcError(`${source}: unsupported config value kind.`);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Normalize one level (flat top-level or the nested `analyze` block) into a
|
|
238
|
+
* partial `AnalyzeOptions`. Rejects unknown keys and two aliases that configure
|
|
239
|
+
* the same option at the same level.
|
|
240
|
+
*/
|
|
241
|
+
const normalizeLevel = (obj, { allowNestedKey }) => {
|
|
242
|
+
const out = {};
|
|
243
|
+
const setBy = new Map();
|
|
244
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
245
|
+
if (allowNestedKey && key === NESTED_KEY)
|
|
246
|
+
continue; // handled separately
|
|
247
|
+
// `Object.hasOwn`, not a truthiness check: a plain-object lookup like
|
|
248
|
+
// `KEY_SPECS["__proto__"]` returns an inherited member (Object.prototype,
|
|
249
|
+
// truthy) and would slip past `if (!spec)`, hitting the wrong error branch
|
|
250
|
+
// instead of the documented "Unknown key" message (#1996 tri-review P3).
|
|
251
|
+
if (!Object.hasOwn(KEY_SPECS, key)) {
|
|
252
|
+
throw new GitNexusRcError(`Unknown key "${key}" in ${GITNEXUS_RC_FILENAME}. ${ALLOWED_KEYS_HINT}`);
|
|
253
|
+
}
|
|
254
|
+
const spec = KEY_SPECS[key];
|
|
255
|
+
const prev = setBy.get(spec.target);
|
|
256
|
+
if (prev && prev !== key) {
|
|
257
|
+
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME}: "${prev}" and "${key}" both configure the same option; set only one.`);
|
|
258
|
+
}
|
|
259
|
+
setBy.set(spec.target, key);
|
|
260
|
+
out[spec.target] = normalizeValue(spec.kind, value, key);
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* Locate, read, parse, validate, and normalize `.gitnexusrc` at `repoRoot`.
|
|
266
|
+
*
|
|
267
|
+
* @returns the normalized config defaults, or `undefined` when no file exists
|
|
268
|
+
* (the normal case). Throws {@link GitNexusRcError} on any problem.
|
|
269
|
+
*/
|
|
270
|
+
export function loadAnalyzeConfig(repoRoot) {
|
|
271
|
+
const filePath = path.join(repoRoot, GITNEXUS_RC_FILENAME);
|
|
272
|
+
let raw;
|
|
273
|
+
try {
|
|
274
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
if (err?.code === 'ENOENT')
|
|
278
|
+
return undefined;
|
|
279
|
+
throw new GitNexusRcError(`Could not read ${GITNEXUS_RC_FILENAME}: ${err.message}`);
|
|
280
|
+
}
|
|
281
|
+
// Strip a leading UTF-8 BOM: Node's 'utf-8' decode keeps it, and JSON.parse
|
|
282
|
+
// then fails with a confusing "Unexpected token" on an otherwise-valid file
|
|
283
|
+
// (#1996 tri-review). Only one leading BOM is stripped; in-string control
|
|
284
|
+
// rejection still applies to the parsed values.
|
|
285
|
+
if (raw.charCodeAt(0) === 0xfeff)
|
|
286
|
+
raw = raw.slice(1);
|
|
287
|
+
let parsed;
|
|
288
|
+
try {
|
|
289
|
+
parsed = JSON.parse(raw);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} is not valid JSON: ${err.message}. ` +
|
|
293
|
+
`Expected a JSON object such as {"defaultBranch": "develop", "skipContextFiles": true}.`);
|
|
294
|
+
}
|
|
295
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
296
|
+
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} must contain a JSON object.`);
|
|
297
|
+
}
|
|
298
|
+
const obj = parsed;
|
|
299
|
+
const flat = normalizeLevel(obj, { allowNestedKey: true });
|
|
300
|
+
let nested = {};
|
|
301
|
+
if (Object.prototype.hasOwnProperty.call(obj, NESTED_KEY)) {
|
|
302
|
+
const nestedRaw = obj[NESTED_KEY];
|
|
303
|
+
if (nestedRaw === null || typeof nestedRaw !== 'object' || Array.isArray(nestedRaw)) {
|
|
304
|
+
throw new GitNexusRcError(`${GITNEXUS_RC_FILENAME} "${NESTED_KEY}" must be a JSON object.`);
|
|
305
|
+
}
|
|
306
|
+
nested = normalizeLevel(nestedRaw, { allowNestedKey: false });
|
|
307
|
+
}
|
|
308
|
+
// Nested `analyze` block overrides flat top-level keys for the same option.
|
|
309
|
+
return { ...flat, ...nested };
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Merge CLI options over `.gitnexusrc` defaults. CLI wins whenever it provides a
|
|
313
|
+
* value — including an explicit `false` (so an explicit CLI off-switch beats a
|
|
314
|
+
* config `true`). Config fills only the genuinely-unset (`undefined`) options.
|
|
315
|
+
*
|
|
316
|
+
* `stats` is special: Commander always materializes it (`true` by default,
|
|
317
|
+
* `false` only when `--no-stats` is passed), so plain `??` can't tell "default
|
|
318
|
+
* on" from "explicitly on". The rule is: a passed `--no-stats` (`stats: false`)
|
|
319
|
+
* always wins; otherwise the config value applies. There is intentionally no
|
|
320
|
+
* `--stats` counter-flag, so config can only turn stats off, not force it back
|
|
321
|
+
* on against a `--no-stats`.
|
|
322
|
+
*
|
|
323
|
+
* `defaultBranch` is NOT resolved here — see {@link resolveDefaultBranch}, which
|
|
324
|
+
* applies the CLI > config > auto-detect > "main" precedence chain.
|
|
325
|
+
*/
|
|
326
|
+
export function mergeAnalyzeOptions(cli, config) {
|
|
327
|
+
if (!config)
|
|
328
|
+
return cli;
|
|
329
|
+
const merged = { ...cli };
|
|
330
|
+
for (const key of Object.keys(config)) {
|
|
331
|
+
if (key === 'stats' || key === 'defaultBranch')
|
|
332
|
+
continue; // handled below / by resolver
|
|
333
|
+
if (merged[key] === undefined) {
|
|
334
|
+
merged[key] = config[key];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (config.stats !== undefined && cli.stats !== false) {
|
|
338
|
+
merged.stats = config.stats;
|
|
339
|
+
}
|
|
340
|
+
return merged;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the default branch threaded into generated context, applying the
|
|
344
|
+
* precedence chain:
|
|
345
|
+
*
|
|
346
|
+
* CLI `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch`
|
|
347
|
+
* > auto-detected `origin/HEAD` > {@link DEFAULT_BRANCH_FALLBACK} ("main").
|
|
348
|
+
*
|
|
349
|
+
* User-supplied values (CLI, config) are validated strictly and throw on bad
|
|
350
|
+
* input. The auto-detected value is best-effort and silently ignored if
|
|
351
|
+
* unusable.
|
|
352
|
+
*/
|
|
353
|
+
export function resolveDefaultBranch(input) {
|
|
354
|
+
if (input.cliBranch !== undefined) {
|
|
355
|
+
return validateBranchName(input.cliBranch, '--default-branch');
|
|
356
|
+
}
|
|
357
|
+
if (input.configBranch !== undefined) {
|
|
358
|
+
return validateBranchName(input.configBranch, `${GITNEXUS_RC_FILENAME} "defaultBranch"`);
|
|
359
|
+
}
|
|
360
|
+
const detected = sanitizeDetectedBranch(input.detectedBranch);
|
|
361
|
+
if (detected)
|
|
362
|
+
return detected;
|
|
363
|
+
return DEFAULT_BRANCH_FALLBACK;
|
|
364
|
+
}
|
package/dist/cli/analyze.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface AnalyzeOptions {
|
|
|
42
42
|
stats?: boolean;
|
|
43
43
|
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
|
|
44
44
|
skipSkills?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Default branch for the generated regression-compare example (#243). From
|
|
47
|
+
* `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a
|
|
48
|
+
* concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main")
|
|
49
|
+
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
|
|
50
|
+
*/
|
|
51
|
+
defaultBranch?: string;
|
|
45
52
|
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
|
|
46
53
|
indexOnly?: boolean;
|
|
47
54
|
/** Index the folder even when no .git directory is present. */
|
package/dist/cli/analyze.js
CHANGED
|
@@ -14,7 +14,8 @@ import cliProgress from 'cli-progress';
|
|
|
14
14
|
import { closeLbug } from '../core/lbug/lbug-adapter.js';
|
|
15
15
|
import { isLbugCheckpointIoError, isWalCorruptionError, parseWalCheckpointThreshold, WAL_RECOVERY_SUGGESTION, } from '../core/lbug/lbug-config.js';
|
|
16
16
|
import { getStoragePaths, getGlobalRegistryPath, RegistryNameCollisionError, AnalysisNotFinalizedError, assertAnalysisFinalized, } from '../storage/repo-manager.js';
|
|
17
|
-
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
|
17
|
+
import { getGitRoot, hasGitDir, getDefaultBranch } from '../storage/git.js';
|
|
18
|
+
import { loadAnalyzeConfig, mergeAnalyzeOptions, resolveDefaultBranch, validateBranchName, GitNexusRcError, } from './analyze-config.js';
|
|
18
19
|
import { runFullAnalysis } from '../core/run-analyze.js';
|
|
19
20
|
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
|
20
21
|
import { warnMissingOptionalGrammars } from './optional-grammars.js';
|
|
@@ -454,14 +455,101 @@ export const analyzeCommand = async (inputPath, options) => {
|
|
|
454
455
|
restoreAnalyzeEnv(envSnap);
|
|
455
456
|
}
|
|
456
457
|
};
|
|
457
|
-
const analyzeCommandImpl = async (inputPath,
|
|
458
|
-
|
|
458
|
+
const analyzeCommandImpl = async (inputPath, cliOptions) => {
|
|
459
|
+
console.log('\n GitNexus Analyzer\n');
|
|
460
|
+
// ── Resolve the target repo root ──────────────────────────────────
|
|
461
|
+
// Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
|
|
462
|
+
// caller's cwd), and config can set defaults that the validation below
|
|
463
|
+
// consumes. `--skip-git` is a CLI-only flag (never a config key), so the raw
|
|
464
|
+
// CLI options are authoritative for repo-root resolution.
|
|
465
|
+
let repoPath;
|
|
466
|
+
if (inputPath) {
|
|
467
|
+
repoPath = path.resolve(inputPath);
|
|
468
|
+
}
|
|
469
|
+
else if (cliOptions?.skipGit) {
|
|
470
|
+
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
|
|
471
|
+
repoPath = path.resolve(process.cwd());
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
const gitRoot = getGitRoot(process.cwd());
|
|
475
|
+
if (!gitRoot) {
|
|
476
|
+
console.log(' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
|
|
477
|
+
process.exitCode = 1;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
repoPath = gitRoot;
|
|
481
|
+
}
|
|
482
|
+
const repoHasGit = hasGitDir(repoPath);
|
|
483
|
+
if (!repoHasGit && !cliOptions?.skipGit) {
|
|
484
|
+
console.log(' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
|
|
485
|
+
process.exitCode = 1;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (!repoHasGit) {
|
|
489
|
+
console.log(' Warning: no .git directory found — commit-tracking and incremental updates disabled.\n');
|
|
490
|
+
}
|
|
491
|
+
// Validate an explicit `--default-branch` up front so its errors are
|
|
492
|
+
// attributed to the flag (with a CLI-specific recovery hint) rather than to
|
|
493
|
+
// `.gitnexusrc`, which the user may not even have (#1996 tri-review).
|
|
494
|
+
if (cliOptions?.defaultBranch !== undefined) {
|
|
495
|
+
try {
|
|
496
|
+
validateBranchName(cliOptions.defaultBranch, '--default-branch');
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
cliError(` ${err instanceof Error ? err.message : String(err)}\n`, {
|
|
500
|
+
recoveryHint: 'default-branch-invalid',
|
|
501
|
+
});
|
|
502
|
+
process.exitCode = 1;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// ── Load .gitnexusrc and merge: CLI flags override config (#243) ───
|
|
507
|
+
// Parse/validate before the progress bar so a malformed config produces an
|
|
508
|
+
// actionable error and exits before any expensive analysis starts.
|
|
509
|
+
let options;
|
|
510
|
+
let resolvedDefaultBranch;
|
|
511
|
+
try {
|
|
512
|
+
const fileConfig = loadAnalyzeConfig(repoPath);
|
|
513
|
+
options = mergeAnalyzeOptions(cliOptions ?? {}, fileConfig);
|
|
514
|
+
// Resolve the default branch threaded into generated context:
|
|
515
|
+
// CLI --default-branch > .gitnexusrc defaultBranch/branch
|
|
516
|
+
// > auto-detected origin/HEAD > "main".
|
|
517
|
+
// Only shell out to git when no branch was configured AND the generated
|
|
518
|
+
// context will actually use it, keeping the common path free of an extra
|
|
519
|
+
// git call. Detection is best-effort and never blocks analyze.
|
|
520
|
+
const cliBranch = cliOptions?.defaultBranch;
|
|
521
|
+
const configBranch = fileConfig?.defaultBranch;
|
|
522
|
+
const willGenerateContext = !options.indexOnly && !options.skipAgentsMd;
|
|
523
|
+
let detectedBranch = null;
|
|
524
|
+
if (cliBranch === undefined &&
|
|
525
|
+
configBranch === undefined &&
|
|
526
|
+
repoHasGit &&
|
|
527
|
+
!cliOptions?.skipGit &&
|
|
528
|
+
willGenerateContext) {
|
|
529
|
+
try {
|
|
530
|
+
detectedBranch = getDefaultBranch(repoPath);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
detectedBranch = null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
resolvedDefaultBranch = resolveDefaultBranch({ cliBranch, configBranch, detectedBranch });
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
const msg = err instanceof GitNexusRcError
|
|
540
|
+
? err.message
|
|
541
|
+
: `Invalid .gitnexusrc: ${err instanceof Error ? err.message : String(err)}`;
|
|
542
|
+
cliError(` ${msg}\n`, { recoveryHint: 'gitnexusrc-invalid' });
|
|
543
|
+
process.exitCode = 1;
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (options.verbose) {
|
|
459
547
|
process.env.GITNEXUS_VERBOSE = '1';
|
|
460
548
|
}
|
|
461
|
-
if (options
|
|
549
|
+
if (options.maxFileSize) {
|
|
462
550
|
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
|
|
463
551
|
}
|
|
464
|
-
if (options
|
|
552
|
+
if (options.workerTimeout) {
|
|
465
553
|
const workerTimeoutSeconds = Number(options.workerTimeout);
|
|
466
554
|
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
|
|
467
555
|
cliError(' --worker-timeout must be at least 1 second.\n');
|
|
@@ -470,7 +558,7 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
470
558
|
}
|
|
471
559
|
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(Math.round(workerTimeoutSeconds * 1000));
|
|
472
560
|
}
|
|
473
|
-
if (options
|
|
561
|
+
if (options.walCheckpointThreshold !== undefined) {
|
|
474
562
|
const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold);
|
|
475
563
|
if (parsed === undefined) {
|
|
476
564
|
cliError(' --wal-checkpoint-threshold must be an integer >= -1.\n');
|
|
@@ -486,7 +574,7 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
486
574
|
// values back-to-back and observe the value they passed, not whatever the
|
|
487
575
|
// previous call leaked.
|
|
488
576
|
let workerPoolSize;
|
|
489
|
-
if (options
|
|
577
|
+
if (options.workers !== undefined) {
|
|
490
578
|
const parsedWorkers = Number(options.workers);
|
|
491
579
|
if (!Number.isInteger(parsedWorkers) || parsedWorkers < 0) {
|
|
492
580
|
cliError(' --workers must be a non-negative integer. ' +
|
|
@@ -501,7 +589,7 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
501
589
|
// sibling-validation pattern (exit before bar.start() — otherwise
|
|
502
590
|
// process.exit() leaves the progress bar's hidden cursor uncleared).
|
|
503
591
|
let embeddingsNodeLimit;
|
|
504
|
-
if (typeof options
|
|
592
|
+
if (typeof options.embeddings === 'string') {
|
|
505
593
|
const parsed = Number(options.embeddings);
|
|
506
594
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
507
595
|
cliError(` --embeddings expects a non-negative integer (got "${options.embeddings}"). ` +
|
|
@@ -511,7 +599,7 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
511
599
|
}
|
|
512
600
|
embeddingsNodeLimit = parsed;
|
|
513
601
|
}
|
|
514
|
-
const embeddingsEnabled = !!options
|
|
602
|
+
const embeddingsEnabled = !!options.embeddings;
|
|
515
603
|
const setPositiveEnv = (optionName, envName, value) => {
|
|
516
604
|
if (value === undefined)
|
|
517
605
|
return true;
|
|
@@ -524,12 +612,12 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
524
612
|
process.env[envName] = String(parsed);
|
|
525
613
|
return true;
|
|
526
614
|
};
|
|
527
|
-
if (!setPositiveEnv('--embedding-threads', 'GITNEXUS_EMBEDDING_THREADS', options
|
|
528
|
-
!setPositiveEnv('--embedding-batch-size', 'GITNEXUS_EMBEDDING_BATCH_SIZE', options
|
|
529
|
-
!setPositiveEnv('--embedding-sub-batch-size', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', options
|
|
615
|
+
if (!setPositiveEnv('--embedding-threads', 'GITNEXUS_EMBEDDING_THREADS', options.embeddingThreads) ||
|
|
616
|
+
!setPositiveEnv('--embedding-batch-size', 'GITNEXUS_EMBEDDING_BATCH_SIZE', options.embeddingBatchSize) ||
|
|
617
|
+
!setPositiveEnv('--embedding-sub-batch-size', 'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', options.embeddingSubBatchSize)) {
|
|
530
618
|
return;
|
|
531
619
|
}
|
|
532
|
-
if (options
|
|
620
|
+
if (options.embeddingDevice) {
|
|
533
621
|
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
|
|
534
622
|
if (!allowed.has(options.embeddingDevice)) {
|
|
535
623
|
cliError(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
|
|
@@ -538,48 +626,21 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
538
626
|
}
|
|
539
627
|
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
|
|
540
628
|
}
|
|
541
|
-
if (options
|
|
629
|
+
if (options.repairFts && options.force) {
|
|
542
630
|
cliError(' Cannot combine `--repair-fts` with `--force`. ' +
|
|
543
631
|
'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n');
|
|
544
632
|
process.exitCode = 1;
|
|
545
633
|
return;
|
|
546
634
|
}
|
|
547
|
-
console.log('\n GitNexus Analyzer\n');
|
|
548
635
|
// `--index-only` is the stronger contract — it suppresses every form of file
|
|
549
636
|
// injection, including community skill writes that `--skills` would normally
|
|
550
637
|
// produce. Surface the override explicitly so users don't wonder why a
|
|
551
638
|
// pipeline re-index ran but no skill files appeared. The pipeline still
|
|
552
|
-
// re-runs (see `force: options
|
|
639
|
+
// re-runs (see `force: options.force || options.skills` below); the warning
|
|
553
640
|
// is purely about the dropped post-index write step.
|
|
554
|
-
if (options
|
|
641
|
+
if (options.indexOnly && options.skills) {
|
|
555
642
|
console.log(' Note: --index-only overrides --skills; community skill files will not be written.\n');
|
|
556
643
|
}
|
|
557
|
-
let repoPath;
|
|
558
|
-
if (inputPath) {
|
|
559
|
-
repoPath = path.resolve(inputPath);
|
|
560
|
-
}
|
|
561
|
-
else if (options?.skipGit) {
|
|
562
|
-
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
|
|
563
|
-
repoPath = path.resolve(process.cwd());
|
|
564
|
-
}
|
|
565
|
-
else {
|
|
566
|
-
const gitRoot = getGitRoot(process.cwd());
|
|
567
|
-
if (!gitRoot) {
|
|
568
|
-
console.log(' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
|
|
569
|
-
process.exitCode = 1;
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
repoPath = gitRoot;
|
|
573
|
-
}
|
|
574
|
-
const repoHasGit = hasGitDir(repoPath);
|
|
575
|
-
if (!repoHasGit && !options?.skipGit) {
|
|
576
|
-
console.log(' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n');
|
|
577
|
-
process.exitCode = 1;
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
if (!repoHasGit) {
|
|
581
|
-
console.log(' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n');
|
|
582
|
-
}
|
|
583
644
|
// If the target repo contains files an optional grammar would parse but
|
|
584
645
|
// that grammar's native binding is absent, warn before analysis so users
|
|
585
646
|
// learn why those files end up unparsed instead of silently getting a
|
|
@@ -698,34 +759,37 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
698
759
|
const t0 = Date.now();
|
|
699
760
|
// ── Run shared analysis orchestrator ───────────────────────────────
|
|
700
761
|
try {
|
|
701
|
-
const skipAll = options
|
|
702
|
-
const skipAgentsMd = skipAll || options
|
|
703
|
-
const skipSkills = skipAll || options
|
|
762
|
+
const skipAll = options.indexOnly;
|
|
763
|
+
const skipAgentsMd = skipAll || options.skipAgentsMd;
|
|
764
|
+
const skipSkills = skipAll || options.skipSkills;
|
|
704
765
|
const result = await runFullAnalysis(repoPath, {
|
|
705
766
|
// Pipeline re-index — OR'd with --skills because skill generation
|
|
706
767
|
// needs a fresh pipelineResult. Has no bearing on the registry
|
|
707
768
|
// collision guard (see allowDuplicateName below).
|
|
708
|
-
force: options
|
|
709
|
-
repairFts: options
|
|
769
|
+
force: options.force || options.skills,
|
|
770
|
+
repairFts: options.repairFts,
|
|
710
771
|
embeddings: embeddingsEnabled,
|
|
711
772
|
embeddingsNodeLimit,
|
|
712
|
-
dropEmbeddings: options
|
|
713
|
-
verbose: options
|
|
714
|
-
skipGit: options
|
|
773
|
+
dropEmbeddings: options.dropEmbeddings,
|
|
774
|
+
verbose: options.verbose,
|
|
775
|
+
skipGit: options.skipGit,
|
|
715
776
|
skipAgentsMd,
|
|
716
777
|
skipSkills,
|
|
778
|
+
// Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
|
|
779
|
+
// threaded into the generated regression-compare example (#243).
|
|
780
|
+
defaultBranch: resolvedDefaultBranch,
|
|
717
781
|
// commander.js `.option('--no-stats', …)` registers the flag as
|
|
718
782
|
// `options.stats` (boolean, default true; `false` when the user
|
|
719
|
-
// passed --no-stats). Reading `options
|
|
783
|
+
// passed --no-stats). Reading `options.noStats` here returns
|
|
720
784
|
// undefined every time, so the flag was a no-op on the markdown
|
|
721
785
|
// rewrite path before this fix. See #1477.
|
|
722
|
-
noStats: options
|
|
723
|
-
registryName: options
|
|
786
|
+
noStats: options.stats === false,
|
|
787
|
+
registryName: options.name,
|
|
724
788
|
// Registry-collision bypass — its own CLI flag, intentionally NOT
|
|
725
789
|
// overloading --force. A user who hits the collision guard should
|
|
726
790
|
// be able to accept the duplicate name without also paying the
|
|
727
791
|
// cost of a full pipeline re-index. See #829 review round 2.
|
|
728
|
-
allowDuplicateName: options
|
|
792
|
+
allowDuplicateName: options.allowDuplicateName,
|
|
729
793
|
// Worker pool size threaded from --workers, replacing the previous
|
|
730
794
|
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
|
|
731
795
|
// env / auto-formula fallback inside the pipeline.
|
|
@@ -742,6 +806,20 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
742
806
|
// that half-finalized state, runFullAnalysis returns alreadyUpToDate
|
|
743
807
|
// on the next invocation unless we check the registry here too.
|
|
744
808
|
await assertAnalysisFinalized(repoPath);
|
|
809
|
+
// The fast path skips context regeneration, but a changed `.gitnexusrc`
|
|
810
|
+
// defaultBranch / `--default-branch` must still take effect. Surgically
|
|
811
|
+
// refresh just the `base_ref` line in AGENTS.md/CLAUDE.md in place,
|
|
812
|
+
// preserving the rest of the block (incl. --skills community rows). No-op
|
|
813
|
+
// when the value already matches, so a routine up-to-date run is silent
|
|
814
|
+
// (#1996 tri-review P2).
|
|
815
|
+
let baseRefRefreshed = [];
|
|
816
|
+
try {
|
|
817
|
+
const { refreshBaseRefLine } = await import('./ai-context.js');
|
|
818
|
+
baseRefRefreshed = (await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })).files;
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
/* best-effort — never fail the fast path over a context refresh */
|
|
822
|
+
}
|
|
745
823
|
clearInterval(elapsedTimer);
|
|
746
824
|
process.removeListener('SIGINT', sigintHandler);
|
|
747
825
|
console.log = origLog;
|
|
@@ -751,6 +829,9 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
751
829
|
console.error = origError;
|
|
752
830
|
bar.stop();
|
|
753
831
|
console.log(' Already up to date\n');
|
|
832
|
+
if (baseRefRefreshed.length > 0) {
|
|
833
|
+
console.log(` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`);
|
|
834
|
+
}
|
|
754
835
|
// Safe to return without process.exit(0) — the early-return path in
|
|
755
836
|
// runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
|
|
756
837
|
return;
|
|
@@ -809,9 +890,13 @@ const analyzeCommandImpl = async (inputPath, options) => {
|
|
|
809
890
|
}, skillResult.skills, {
|
|
810
891
|
skipAgentsMd,
|
|
811
892
|
skipSkills,
|
|
893
|
+
// Same resolved branch as the main run (#243) so the --skills
|
|
894
|
+
// re-generation of AGENTS.md/CLAUDE.md does not revert base_ref
|
|
895
|
+
// to "main".
|
|
896
|
+
defaultBranch: resolvedDefaultBranch,
|
|
812
897
|
// Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
|
|
813
898
|
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
|
|
814
|
-
noStats: options
|
|
899
|
+
noStats: options.stats === false,
|
|
815
900
|
});
|
|
816
901
|
}
|
|
817
902
|
}
|
|
@@ -12,7 +12,7 @@ import { type CliMessageKey, type CliMessageVars } from './i18n/index.js';
|
|
|
12
12
|
* Consumers can import this type to narrow log-record `recoveryHint`
|
|
13
13
|
* fields without restating the literal list.
|
|
14
14
|
*/
|
|
15
|
-
export type RecoveryHint = 'wal-corruption' | 'wal-checkpoint-threshold' | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' | 'local-embedding-unsupported' | 'large-repo' | 'npm-resolution' | 'module-not-found';
|
|
15
|
+
export type RecoveryHint = 'wal-corruption' | 'wal-checkpoint-threshold' | 'heap-oom-respawn' | 'native-worker-abort' | 'hf-endpoint-unreachable' | 'local-embedding-unsupported' | 'large-repo' | 'npm-resolution' | 'module-not-found' | 'gitnexusrc-invalid' | 'default-branch-invalid';
|
|
16
16
|
/**
|
|
17
17
|
* Common shape for the optional structured-field bag passed to
|
|
18
18
|
* `cliError`/`cliWarn`/`cliInfo`. Typed so the `recoveryHint` slot is
|
package/dist/cli/index.js
CHANGED
|
@@ -27,6 +27,8 @@ program
|
|
|
27
27
|
.option('--skills', 'Generate repo-specific skill files from detected communities ' +
|
|
28
28
|
'(no-op when --index-only is also set).')
|
|
29
29
|
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
|
30
|
+
.option('--default-branch <branch>', 'Default branch used in the generated regression-compare example (base_ref). ' +
|
|
31
|
+
'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".')
|
|
30
32
|
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
|
|
31
33
|
.option('--skip-skills', 'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
|
|
32
34
|
'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
|
|
@@ -48,6 +48,13 @@ export interface AnalyzeOptions {
|
|
|
48
48
|
noStats?: boolean;
|
|
49
49
|
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
|
|
50
50
|
skipSkills?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Default branch threaded into generated AGENTS.md / CLAUDE.md so the
|
|
53
|
+
* regression-compare example uses the configured branch instead of a
|
|
54
|
+
* hardcoded "main" (#243). Resolved by the CLI; `undefined` here keeps the
|
|
55
|
+
* "main" fallback for non-CLI callers (e.g. the server analyze worker).
|
|
56
|
+
*/
|
|
57
|
+
defaultBranch?: string;
|
|
51
58
|
/**
|
|
52
59
|
* User-provided alias for the registry `name` (#829). When set,
|
|
53
60
|
* forwarded to `registerRepo` so the indexed repo is stored under
|
package/dist/core/run-analyze.js
CHANGED
package/dist/storage/git.d.ts
CHANGED
|
@@ -107,6 +107,17 @@ export declare const hasGitDir: (dirPath: string) => boolean;
|
|
|
107
107
|
* `<rig>/refinery/rig/` layouts).
|
|
108
108
|
*/
|
|
109
109
|
export declare const getRemoteOriginUrl: (repoPath: string) => string | null;
|
|
110
|
+
/**
|
|
111
|
+
* Best-effort detection of the repository's default branch (#243).
|
|
112
|
+
*
|
|
113
|
+
* Reads `git symbolic-ref --short refs/remotes/origin/HEAD`, which resolves to
|
|
114
|
+
* the short ref `origin/<branch>` that the local `origin/HEAD` points at, and
|
|
115
|
+
* strips the `origin/` prefix. This is a purely local lookup — it never makes a
|
|
116
|
+
* network call. Returns `null` when there is no git repo, no `origin` remote, no
|
|
117
|
+
* `origin/HEAD` (e.g. it was never set by clone, or the repo is detached), or
|
|
118
|
+
* git is unavailable, so callers can fall back to a configured/default branch.
|
|
119
|
+
*/
|
|
120
|
+
export declare const getDefaultBranch: (repoPath: string) => string | null;
|
|
110
121
|
/**
|
|
111
122
|
* Sanitize a repository name to prevent argument injection and ensure
|
|
112
123
|
* cross-platform filesystem compatibility.
|
package/dist/storage/git.js
CHANGED
|
@@ -265,6 +265,36 @@ export const getRemoteOriginUrl = (repoPath) => {
|
|
|
265
265
|
return null;
|
|
266
266
|
}
|
|
267
267
|
};
|
|
268
|
+
/**
|
|
269
|
+
* Best-effort detection of the repository's default branch (#243).
|
|
270
|
+
*
|
|
271
|
+
* Reads `git symbolic-ref --short refs/remotes/origin/HEAD`, which resolves to
|
|
272
|
+
* the short ref `origin/<branch>` that the local `origin/HEAD` points at, and
|
|
273
|
+
* strips the `origin/` prefix. This is a purely local lookup — it never makes a
|
|
274
|
+
* network call. Returns `null` when there is no git repo, no `origin` remote, no
|
|
275
|
+
* `origin/HEAD` (e.g. it was never set by clone, or the repo is detached), or
|
|
276
|
+
* git is unavailable, so callers can fall back to a configured/default branch.
|
|
277
|
+
*/
|
|
278
|
+
export const getDefaultBranch = (repoPath) => {
|
|
279
|
+
try {
|
|
280
|
+
const ref = execSync('git symbolic-ref --short refs/remotes/origin/HEAD', {
|
|
281
|
+
cwd: repoPath,
|
|
282
|
+
// Suppress stderr -- see getCurrentCommit comment and #1172. Without it,
|
|
283
|
+
// git prints "fatal: ref refs/remotes/origin/HEAD is not a symbolic ref"
|
|
284
|
+
// to the user's terminal on repos that never set origin/HEAD.
|
|
285
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
286
|
+
windowsHide: true,
|
|
287
|
+
})
|
|
288
|
+
.toString()
|
|
289
|
+
.trim();
|
|
290
|
+
if (!ref)
|
|
291
|
+
return null;
|
|
292
|
+
return ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
268
298
|
/**
|
|
269
299
|
* Sanitize a repository name to prevent argument injection and ensure
|
|
270
300
|
* cross-platform filesystem compatibility.
|
package/package.json
CHANGED