gitnexus 1.6.13-rc.69 → 1.6.13-rc.70

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.
@@ -65,18 +65,18 @@ export const generateSkillFiles = async (repoPath, projectName, pipelineResult)
65
65
  /* mirror root may not exist yet */
66
66
  }
67
67
  }
68
- if (!communityResult || !communityResult.memberships.length) {
68
+ const memberships = communityResult?.rawMemberships ?? communityResult?.memberships ?? [];
69
+ if (!communityResult || !memberships.length) {
69
70
  console.log('\n Skills: no communities detected, skipping skill generation');
70
71
  return { skills: [], outputPath: outputDir };
71
72
  }
72
73
  console.log('\n Generating repo-specific skills...');
73
- // Step 1: Build communities from memberships (not the filtered communities array).
74
- // The community processor skips singletons from its communities array but memberships
75
- // include ALL assignments. For repos with sparse CALLS edges, the communities array
76
- // can be empty while memberships still has useful groupings.
74
+ // Step 1: Use raw assignments for the fallback when all communities were
75
+ // filtered as singletons. Same-folder aggregation can still produce skills
76
+ // for these sparse graphs without emitting dangling MEMBER_OF edges.
77
77
  const communities = communityResult.communities.length > 0
78
78
  ? communityResult.communities
79
- : buildCommunitiesFromMemberships(communityResult.memberships, graph, repoPath);
79
+ : buildCommunitiesFromMemberships(memberships, graph, repoPath);
80
80
  const aggregated = aggregateCommunities(communities);
81
81
  // Step 2: Filter to significant communities
82
82
  // Keep communities with >= 3 symbols after aggregation.
@@ -89,8 +89,8 @@ export const generateSkillFiles = async (repoPath, projectName, pipelineResult)
89
89
  return { skills: [], outputPath: outputDir };
90
90
  }
91
91
  // Step 3: Build lookup maps
92
- const membershipsByComm = buildMembershipMap(communityResult.memberships);
93
- const nodeIdToCommunityLabel = buildNodeCommunityLabelMap(communityResult.memberships, communities);
92
+ const membershipsByComm = buildMembershipMap(memberships);
93
+ const nodeIdToCommunityLabel = buildNodeCommunityLabelMap(memberships, communities);
94
94
  // Step 4: Ensure the shared project-skill root exists. Never clear it: it
95
95
  // also contains user-authored and standard GitNexus skills.
96
96
  await fs.mkdir(outputDir, { recursive: true });
@@ -120,7 +120,7 @@ export const generateSkillFiles = async (repoPath, projectName, pipelineResult)
120
120
  // Gather entry points
121
121
  const entryPoints = gatherEntryPoints(members);
122
122
  // Gather execution flows
123
- const flows = gatherFlows(community.rawIds, processResult?.processes || []);
123
+ const flows = gatherFlows(community.rawIds, members, processResult?.processes || []);
124
124
  // Gather cross-community connections
125
125
  const connections = gatherCrossConnections(community.rawIds, community.label, membershipsByComm, nodeIdToCommunityLabel, graph);
126
126
  // Generate kebab name
@@ -392,13 +392,18 @@ const gatherEntryPoints = (members) => {
392
392
  /**
393
393
  * @brief Gather execution flows touching this community
394
394
  * @param {string[]} rawIds - Raw community IDs for this aggregated community
395
+ * @param {MemberSymbol[]} members - Member symbols, including raw singleton assignments
395
396
  * @param {ProcessNode[]} processes - All detected processes
396
- * @returns {ProcessNode[]} Processes whose communities intersect rawIds, sorted by stepCount
397
+ * @returns {ProcessNode[]} Processes matching the community IDs or member symbols, sorted by stepCount
397
398
  */
398
- const gatherFlows = (rawIds, processes) => {
399
+ const gatherFlows = (rawIds, members, processes) => {
399
400
  const rawIdSet = new Set(rawIds);
401
+ const memberIds = new Set(members.map((member) => member.id));
400
402
  return processes
401
- .filter((proc) => proc.communities.some((cid) => rawIdSet.has(cid)))
403
+ .filter((proc) => proc.communities.some((cid) => rawIdSet.has(cid)) ||
404
+ // Filtered singleton communities are absent from process metadata,
405
+ // but their symbols still participate in detected execution traces.
406
+ proc.trace.some((nodeId) => memberIds.has(nodeId)))
402
407
  .sort((a, b) => b.stepCount - a.stepCount);
403
408
  };
404
409
  /**
@@ -30,6 +30,7 @@ export declare const createWatchIgnorePredicate: (repoPath: string, options?: Ig
30
30
  /**
31
31
  * Create a glob-compatible ignore filter combining:
32
32
  * - .gitignore / .gitnexusignore patterns (via `ignore` package)
33
+ * - nested .gitignore files, scoped to their own directory (#2675)
33
34
  * - Hardcoded DEFAULT_IGNORE_LIST, IGNORED_EXTENSIONS, IGNORED_FILES
34
35
  *
35
36
  * Returns an IgnoreLike object for glob's `ignore` option,
@@ -1,5 +1,5 @@
1
1
  import ignore from 'ignore';
2
- import { existsSync } from 'fs';
2
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, openSync, readFileSync, } from 'fs';
3
3
  import fs from 'fs/promises';
4
4
  import nodePath from 'path';
5
5
  import { readRepoControlFile } from './repo-control-file.js';
@@ -472,9 +472,152 @@ const hasExplicitUnignore = (ig, rel) => {
472
472
  }
473
473
  return false;
474
474
  };
475
+ /**
476
+ * Read a nested `.gitignore` only if it is a regular file, not a symlink.
477
+ *
478
+ * git does not follow a symlinked `.gitignore` in the working tree, and
479
+ * reading one could pull rules from outside the repository. Where the
480
+ * platform supports it, the file is opened with O_NOFOLLOW (a symlink fails
481
+ * with ELOOP). Windows has no O_NOFOLLOW, so there the path is lstat'ed after
482
+ * opening and must be the same regular file as the open descriptor. Either
483
+ * way the content is read through the descriptor that was checked, never by
484
+ * path, so the file cannot be swapped between the check and the read.
485
+ *
486
+ * The open also passes O_NONBLOCK where it exists. Opening a FIFO for reading
487
+ * blocks in open(2) until a writer appears, so a `.gitignore` that is a FIFO
488
+ * would hang the scan before the isFile() check could reject it (glob's
489
+ * ignore callback is synchronous). The flag makes that open return at once
490
+ * and changes nothing for a regular file. Same reasoning as readBoundedFile
491
+ * in src/core/ingestion/asyncapi/document.ts.
492
+ */
493
+ const readNestedGitignore = (filePath) => {
494
+ const noFollow = fsConstants.O_NOFOLLOW;
495
+ const nonBlock = fsConstants.O_NONBLOCK;
496
+ const fd = openSync(filePath, fsConstants.O_RDONLY | (noFollow ?? 0) | (nonBlock ?? 0));
497
+ try {
498
+ const stat = fstatSync(fd);
499
+ if (!stat.isFile())
500
+ return null;
501
+ if (noFollow === undefined) {
502
+ const link = lstatSync(filePath);
503
+ if (!link.isFile() || link.ino !== stat.ino || link.dev !== stat.dev)
504
+ return null;
505
+ }
506
+ return readFileSync(fd, 'utf-8');
507
+ }
508
+ finally {
509
+ closeSync(fd);
510
+ }
511
+ };
512
+ /**
513
+ * Resolve `.gitignore` files below the repository root (#2675).
514
+ *
515
+ * `loadIgnoreRules` only reads the root `.gitignore`, so a monorepo package
516
+ * or checked-out submodule with its own `.gitignore` had its generated
517
+ * output indexed anyway. Each nested file is read lazily (glob's filter is
518
+ * synchronous) and cached per directory, and its patterns are matched
519
+ * against the path relative to that directory, like git does.
520
+ *
521
+ * Returns the effective decision when nested rules affect the path or an
522
+ * ancestor, and `undefined` otherwise. Root rules participate so a directory
523
+ * negation does not erase independent root exclusions for its children.
524
+ * The caller still gives `.gitnexusignore` its higher precedence.
525
+ */
526
+ const createNestedGitignoreMatcher = (repoPath, rootRules) => {
527
+ const rulesFor = (dirRel) => {
528
+ let rules = null;
529
+ const filePath = nodePath.join(repoPath, dirRel, '.gitignore');
530
+ try {
531
+ const content = readNestedGitignore(filePath);
532
+ if (content !== null)
533
+ rules = ignore().add(content);
534
+ }
535
+ catch (err) {
536
+ const code = err.code;
537
+ if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') {
538
+ logger.warn(` Warning: could not read ${filePath}: ${err.message}`);
539
+ }
540
+ }
541
+ return rules;
542
+ };
543
+ const relativeTo = (base, rel) => base ? rel.slice(base.length + 1) : rel;
544
+ const match = (scopes, rel, isDirectory) => {
545
+ for (let i = scopes.length - 1; i >= 0; i--) {
546
+ const { base, rules } = scopes[i];
547
+ const sub = relativeTo(base, rel);
548
+ const result = rules.test(isDirectory ? `${sub}/` : sub);
549
+ if (result.ignored || result.unignored) {
550
+ return { ignored: result.ignored, nested: base !== '' };
551
+ }
552
+ }
553
+ return undefined;
554
+ };
555
+ const contexts = new Map([
556
+ [
557
+ '',
558
+ {
559
+ scopes: rootRules ? [{ base: '', rules: rootRules }] : [],
560
+ ignored: false,
561
+ nested: false,
562
+ },
563
+ ],
564
+ ]);
565
+ const contextFor = (dir) => {
566
+ const cached = contexts.get(dir);
567
+ if (cached)
568
+ return cached;
569
+ const parentDir = nodePath.posix.dirname(dir);
570
+ const parent = contextFor(parentDir === '.' ? '' : parentDir);
571
+ // A .gitignore inside an excluded directory cannot bring that directory
572
+ // back. Do not read rules below a parent that traversal would prune.
573
+ if (parent.ignored) {
574
+ contexts.set(dir, parent);
575
+ return parent;
576
+ }
577
+ const result = match(parent.scopes, dir, true);
578
+ const context = {
579
+ scopes: parent.scopes,
580
+ ignored: result?.ignored ?? false,
581
+ nested: parent.nested || (result?.nested ?? false),
582
+ };
583
+ if (!context.ignored) {
584
+ context.scopes = parent.scopes.map(({ base, rules }) => {
585
+ const sub = relativeTo(base, dir);
586
+ if (!rules.test(`${sub}/`).ignored)
587
+ return { base, rules };
588
+ // A deeper rule let us enter this directory. Clear only its inherited
589
+ // exclusion in the shallower layer; child rules must still be tested.
590
+ // Keep patterns in their original scope, and escape this literal path.
591
+ const literal = sub.replace(/[\\*?\[\]]/g, '\\$&');
592
+ return {
593
+ base,
594
+ rules: ignore()
595
+ .add(rules)
596
+ .add({ pattern: `!/${literal}/` }),
597
+ };
598
+ });
599
+ const rules = rulesFor(dir);
600
+ if (rules)
601
+ context.scopes.push({ base: dir, rules });
602
+ }
603
+ contexts.set(dir, context);
604
+ return context;
605
+ };
606
+ return (rel, isDirectory) => {
607
+ const parentDir = nodePath.posix.dirname(rel);
608
+ const parent = contextFor(parentDir === '.' ? '' : parentDir);
609
+ if (parent.ignored)
610
+ return parent.nested ? true : undefined;
611
+ const result = match(parent.scopes, rel, isDirectory);
612
+ if (parent.nested || result?.nested)
613
+ return result?.ignored ?? false;
614
+ return undefined;
615
+ };
616
+ };
475
617
  /**
476
618
  * Create a glob-compatible ignore filter combining:
477
619
  * - .gitignore / .gitnexusignore patterns (via `ignore` package)
620
+ * - nested .gitignore files, scoped to their own directory (#2675)
478
621
  * - Hardcoded DEFAULT_IGNORE_LIST, IGNORED_EXTENSIONS, IGNORED_FILES
479
622
  *
480
623
  * Returns an IgnoreLike object for glob's `ignore` option,
@@ -491,6 +634,13 @@ const hasExplicitUnignore = (ig, rel) => {
491
634
  */
492
635
  export const createIgnoreFilter = async (repoPath, options) => {
493
636
  const ig = await loadIgnoreRules(repoPath, options);
637
+ const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE;
638
+ const nestedIgnores = skipGitignore ? null : createNestedGitignoreMatcher(repoPath, ig);
639
+ // A nested negation outranks the root .gitignore, as in git, but not the
640
+ // user's .gitnexusignore, so keep a matcher for that file on its own.
641
+ const nexusIgnore = nestedIgnores
642
+ ? await loadIgnoreRules(repoPath, { ...options, noGitignore: true, noGlobalIgnore: true })
643
+ : null;
494
644
  return {
495
645
  ignored(p) {
496
646
  // The `ignore` package expects POSIX separators; path-scurry can surface
@@ -498,6 +648,27 @@ export const createIgnoreFilter = async (repoPath, options) => {
498
648
  const rel = p.relative().replace(/\\/g, '/');
499
649
  if (!rel)
500
650
  return false;
651
+ // Nested .gitignore files below the root (#2675). .gitnexusignore
652
+ // comes first, then the deepest nested .gitignore, which outranks the
653
+ // root .gitignore as in git. The nested matcher preserves independent
654
+ // root exclusions; a nested negation never rescues a hardcoded default.
655
+ // With no nested opinion the original order below applies unchanged.
656
+ if (nestedIgnores) {
657
+ if (nexusIgnore) {
658
+ if (hasExplicitUnignore(nexusIgnore, rel) && !ig?.ignores(rel))
659
+ return false;
660
+ if (nexusIgnore.ignores(rel))
661
+ return true;
662
+ }
663
+ const nested = nestedIgnores(rel, false);
664
+ if (nested === true)
665
+ return true;
666
+ if (nested === false) {
667
+ if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel))
668
+ return false;
669
+ return shouldIgnorePath(rel);
670
+ }
671
+ }
501
672
  // User's .gitnexusignore negation takes precedence over hardcoded
502
673
  // rules (#771). If any ancestor or the path itself was explicitly
503
674
  // unignored AND no more-specific rule re-ignores this exact path,
@@ -519,6 +690,25 @@ export const createIgnoreFilter = async (repoPath, options) => {
519
690
  // list check below is defense-in-depth — do not remove `dot: false`
520
691
  // assuming this covers it.
521
692
  const rel = p.relative().replace(/\\/g, '/');
693
+ // Nested .gitignore files below the root (#2675), same precedence as in
694
+ // `ignored` above.
695
+ if (nestedIgnores && rel) {
696
+ if (nexusIgnore) {
697
+ if (hasExplicitUnignore(nexusIgnore, rel) && !ig?.ignores(rel + '/')) {
698
+ return false;
699
+ }
700
+ if (nexusIgnore.ignores(rel + '/'))
701
+ return true;
702
+ }
703
+ const nested = nestedIgnores(rel, true);
704
+ if (nested === true)
705
+ return true;
706
+ if (nested === false) {
707
+ if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/'))
708
+ return false;
709
+ return isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel));
710
+ }
711
+ }
522
712
  // User's .gitnexusignore negation takes precedence (#771) — if the
523
713
  // user explicitly unignored this directory or any ancestor via a
524
714
  // !pattern rule, allow descent even if the directory name is in
@@ -62,7 +62,10 @@ export interface CommunityMembership {
62
62
  }
63
63
  export interface CommunityDetectionResult {
64
64
  communities: CommunityNode[];
65
+ /** Assignments to retained communities, safe to emit as MEMBER_OF edges. */
65
66
  memberships: CommunityMembership[];
67
+ /** All Leiden assignments, including filtered singletons. Optional for legacy producers. */
68
+ rawMemberships?: CommunityMembership[];
66
69
  stats: {
67
70
  totalCommunities: number;
68
71
  modularity: number;
@@ -107,6 +107,7 @@ export const processCommunities = async (knowledgeGraph, onProgress, options = {
107
107
  return {
108
108
  communities: [],
109
109
  memberships: [],
110
+ rawMemberships: [],
110
111
  stats: {
111
112
  totalCommunities: 0,
112
113
  modularity: 0,
@@ -124,18 +125,23 @@ export const processCommunities = async (knowledgeGraph, onProgress, options = {
124
125
  // Step 3: Create community nodes with heuristic labels
125
126
  const communityNodes = createCommunityNodes(details.communities, details.count, graph, knowledgeGraph);
126
127
  onProgress?.('Creating membership edges...', 80);
127
- // Step 4: Create membership mappings
128
+ // Step 4: Preserve all assignments for skill generation, but only emit
129
+ // memberships for retained communities with a corresponding graph node.
130
+ const retainedCommunityIds = new Set(communityNodes.map((community) => community.id));
128
131
  const memberships = [];
132
+ const rawMemberships = [];
129
133
  Object.entries(details.communities).forEach(([nodeId, communityNum]) => {
130
- memberships.push({
131
- nodeId,
132
- communityId: `comm_${communityNum}`,
133
- });
134
+ const communityId = `comm_${communityNum}`;
135
+ const membership = { nodeId, communityId };
136
+ rawMemberships.push(membership);
137
+ if (retainedCommunityIds.has(communityId))
138
+ memberships.push(membership);
134
139
  });
135
140
  onProgress?.('Community detection complete!', 100);
136
141
  return {
137
142
  communities: communityNodes,
138
143
  memberships,
144
+ rawMemberships,
139
145
  stats: {
140
146
  totalCommunities: details.count,
141
147
  modularity: details.modularity,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.69",
3
+ "version": "1.6.13-rc.70",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",