@niroai/niro 0.13.0 → 0.15.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/CHANGELOG.md CHANGED
@@ -4,6 +4,115 @@ Notable changes to `@niroai/niro`. Dates are release dates.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.15.0 — 2026-09-21
8
+
9
+ ### Twenty more file types are offered for upload
10
+
11
+ `niro init` and the folder sync decide which files to offer the server from a fixed list of
12
+ extensions (`src/lib/folderSync.js`). That list was a hand-kept copy of the server's own list and
13
+ had fallen behind it in two places:
14
+
15
+ - the ten C++ extensions the parser routes to its C++ grammar besides `.cpp`, `.h` and `.hpp`:
16
+ `.cc`, `.cxx`, `.hxx`, `.hh`, `.h++`, `.c++`, `.tcc`, `.ipp`, `.inl`, `.tpp`. A C++ project with
17
+ `include/widget.hxx` and `detail/vector.tcc` never had those two files in its graph, because the
18
+ CLI never offered them.
19
+ - ten more the parser has always understood but the CLI never offered: `.kts`, `.csx`, `.rake`,
20
+ `.gemspec`, `.phtml`, `.php3`, `.php4`, `.php5`, `.pyi`, `.pyw`.
21
+
22
+ A test now reads the server's Java list and fails if the two drift apart again
23
+ (`test/sourceExtensionDrift.test.js`); the server carries the mirror-image test.
24
+
25
+ The server's own filter remains the final say, so an older CLI simply offers fewer files and a
26
+ newer CLI against an older server has its extra offers declined. Either order of upgrade is safe;
27
+ the new files reach the graph once both sides are current (ai-orchestrator `cadb5360` or later).
28
+
29
+ Note: 0.14.0 was cut in this repository but never published to npm, so upgrading from 0.13.0 brings
30
+ the 0.14.0 change below as well.
31
+
32
+ ### A file whose name contains `+` no longer stops a folder syncing
33
+
34
+ After `niro init`, the server replies with the patterns saying which paths belong to the
35
+ project, and the watch daemon checks every path against them: on each file event, on each
36
+ delta and backstop pass, and once more before it asks the server to delete anything.
37
+
38
+ For a file at the top level of a repository, the pattern the server sends is the filename
39
+ itself. The daemon turned patterns into regular expressions but escaped only the dot, so a
40
+ name carrying a character that means something in a regular expression was read as an
41
+ instruction rather than as a name. With `main.c++` at a repository root, `main.c++` became
42
+ `/^main\.c++$/`, which is not a valid expression. Every sync pass for that folder then failed.
43
+ The daemon stayed up and printed nothing, so the folder simply went quiet.
44
+
45
+ The `.c++` and `.h++` file types added in this same release are what would have exposed it. The
46
+ same flaw had a quieter form that needed no C++ at all: a root-level file called `app (copy).js`
47
+ built a valid expression with a capture group, so the file stopped matching its own pattern and
48
+ was never synced again. A directory named `libstdc++` hit the throwing form, because saying yes
49
+ to "New directory detected. Add to sync?" stores the directory's own name as a pattern.
50
+
51
+ The matcher now escapes every character that has a special meaning, leaving only `*` and `**`
52
+ as wildcards, so a pattern can no longer fail to compile. Behaviour for patterns that already
53
+ worked is unchanged: old and new were compared over 10,701 path-and-pattern pairs taken from
54
+ three real repositories and agreed on every one.
55
+
56
+ ## 0.14.0 — 2026-09-19
57
+
58
+ ### `niro init` on a folder of repos now indexes each repo, not the folder
59
+
60
+ If you run `niro init` in a folder that is not itself a git repository but contains repositories —
61
+ an `apps/` folder, a `~/projects/github/<org>/` folder, anything that groups checkouts — each
62
+ repository is now onboarded in its own right, with its own git URL and its own branch, into one
63
+ project.
64
+
65
+ Before this, the folder itself was registered as a single repo. A folder has no git remote, so it
66
+ got a synthetic `local://<folder-name>` URL and no branch at all, and the monorepo splitter then
67
+ tried to recover the structure by reading folder layout. On one customer's tree that produced 92
68
+ internal repos from 14 real ones: 92 of 92 carried `local://apps`, none had a branch, 58 of them
69
+ were vendored copies and reference checkouts of other people's projects, and 45% of the indexing
70
+ spend went on code the customer had never written. Because no repo had a URL of its own, MCP could
71
+ not match a repo from inside its own folder, and kept offering to create a temporary project in
72
+ code that was already indexed.
73
+
74
+ The repositories underneath were the boundaries the customer had already declared. Reading them is
75
+ better than re-deriving them.
76
+
77
+ What you will see:
78
+
79
+ ```
80
+ apps is not a git repository, but contains 14 that are.
81
+ Niro will index each repo separately, with its own git URL and branch.
82
+
83
+ + voibe-backend
84
+ + voibe_app
85
+ - legal-docs — not a git repository, NOT indexed
86
+ ```
87
+
88
+ Folders holding code but no repository are reported rather than skipped in silence: from that point
89
+ on, editing a file in one of them does nothing, and you should know which ones they are. Run
90
+ `niro init` inside one to add it.
91
+
92
+ The scan stops at the first repository it finds down any path, so a repository vendored inside
93
+ another — a submodule, a `third_party/` checkout — is indexed as part of its parent rather than as a
94
+ service of its own.
95
+
96
+ Unchanged: a folder that IS a git repository still indexes whole, submodules and all. A folder with
97
+ no git anywhere still uploads as one repo, as before.
98
+
99
+ ### A folder whose repositories sit deeper than one level is no longer refused
100
+
101
+ `niro init` requires a build file at the folder or just below it, which is right for a folder of
102
+ documents and wrong for `~/projects/github/<org>/<repo>`, where the repositories are three levels
103
+ down and every folder above them is empty. That was refused with "No project found" and a pointer to
104
+ `--force`, and `--force` would then upload the entire tree as one repo. A folder holding
105
+ repositories is now exempt from that rule, because it has already declared its projects.
106
+
107
+ The refusal message for a folder that genuinely holds nothing indexable now says what Niro looks
108
+ for, and suggests running from the folder where the code actually is.
109
+
110
+ ### Fixed
111
+
112
+ - The background watcher was being installed once per repository when onboarding several at once,
113
+ asking for your password each time. It is one process; it is now set up once per run.
114
+
115
+
7
116
  ## 0.13.0 — 2026-09-08
8
117
 
9
118
  ### Upgrading
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@niroai/niro",
3
- "version": "0.13.0",
4
- "description": "Niro — one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
3
+ "version": "0.15.0",
4
+ "description": "Niro \u2014 one package: the niro CLI plus the Niro MCP server (code intelligence for AI coding assistants).",
5
5
  "keywords": [
6
6
  "niro",
7
7
  "mcp",
@@ -41,7 +41,7 @@ const color = require("../lib/color");
41
41
  const watchDaemon = require("../lib/watchDaemon");
42
42
  const { login } = require("./login");
43
43
  const { watchStatus } = require("./build");
44
- const { currentIndent } = require("../lib/nestedOutput");
44
+ const { currentIndent, withIndent } = require("../lib/nestedOutput");
45
45
 
46
46
  /** Depth as spaces, for the \r-written lines that bypass withIndent's console wrappers. */
47
47
  function nestPad() {
@@ -99,9 +99,24 @@ async function init(pathArg, opts = {}) {
99
99
  // repo for must not start failing its re-run just because it has no build file.
100
100
  // See src/lib/onboardTarget.js.
101
101
  const knownEntry = watchState.get(targetDir, apiUrlBeforeLogin);
102
+ // A FOLDER THAT HOLDS GIT REPOS ALREADY DECLARES ITS PROJECTS, just not with a build file.
103
+ //
104
+ // The descriptor rule looks for package.json / pom.xml / go.mod in this folder or just below it,
105
+ // and refuses when it finds none. That is right for a folder of holiday photos. It is wrong for
106
+ // ~/projects/github/<org>/<repo>, where the repos are three levels down and the folders above them
107
+ // hold nothing at all: the scan found no descriptor, the user was told "No project found" and
108
+ // pointed at --force, and --force would then upload the whole tree as ONE repo — the exact outcome
109
+ // this release exists to prevent. The comment on CHILD_SCAN_MAX_DEPTH calls that layout the
110
+ // commonest one on disk. Reproduced on a demo tree on 2026-09-19 before this line existed.
111
+ //
112
+ // Cheap because it stops at the first repo it sees, and only runs when the descriptor scan already
113
+ // came back empty. Every other refusal in ensureOnboardable (home directory, filesystem root) still
114
+ // applies: this exempts the descriptor rule alone.
115
+ const holdsGitRepos = () => collectNestedGitRepos(targetDir).repos.length > 0;
102
116
  if (!onboardTarget.ensureOnboardable(targetDir, {
103
117
  ...opts,
104
118
  alreadyRegistered: !!(knownEntry && knownEntry.gitId),
119
+ hasGitReposBeneath: holdsGitRepos,
105
120
  })) {
106
121
  process.exitCode = 1;
107
122
  return;
@@ -244,6 +259,25 @@ async function init(pathArg, opts = {}) {
244
259
  // onboarded, so resolve each immediate git-repo child against the server too — this
245
260
  // catches children indexed via the web UI / as separate repos.
246
261
  const childScan = await scanIndexedChildren(targetDir, opts);
262
+
263
+ // THE FOLDER IS NOT A REPO BUT CONTAINS SOME: onboard each of them, not the folder.
264
+ //
265
+ // `git.inspect` walks UPWARDS, so repoRoot can be an ANCESTOR of targetDir. "This folder is a
266
+ // repo" therefore has to compare paths, not just test for a truthy repoRoot.
267
+ //
268
+ // Why this exists. Onboarding the parent registers ONE repo, and a folder with no git remote gets
269
+ // the synthetic `local://<name>` URL from step 4. Measured on a customer's tree on 2026-09-18:
270
+ // `niro init` in an `apps/` folder holding 14 real repos produced 92 internal repos, every one of
271
+ // them carrying `local://apps` and a null branch — so no repo had its own identity, none could be
272
+ // matched by MCP from its own folder (it kept offering to create a temp project inside indexed
273
+ // code), and nothing could track a per-repo branch. The repos beneath were the customer's own
274
+ // declared boundaries; the split was re-deriving them, badly, from folder structure.
275
+ const targetIsRepoRoot = !!gitInfo.repoRoot
276
+ && path.resolve(gitInfo.repoRoot) === path.resolve(targetDir);
277
+ if (!targetIsRepoRoot && childScan.all.length) {
278
+ return onboardDiscoveredRepos({ targetDir, defaultName, childScan, opts });
279
+ }
280
+
247
281
  if (childScan.indexed.length) {
248
282
  console.error(fail(`\n This folder contains ${childScan.indexed.length} repo(s) already indexed in Niro:`));
249
283
  for (const c of childScan.indexed) {
@@ -642,7 +676,14 @@ async function init(pathArg, opts = {}) {
642
676
  }
643
677
 
644
678
  // --defaults promised "no prompts": never surface the PM2 install offer there.
645
- const daemon = await watchDaemon.ensureWatchDaemon({ skipPrompts: !!opts.defaults });
679
+ // INSTALLED ONCE PER RUN, not once per repo. The daemon is a single global PM2 process that reads
680
+ // ~/.niro/projects.json, so a second install is a no-op that still shells out to `pm2 startup`,
681
+ // which asks for sudo. Onboarding a folder of 6 repos asked six times and printed six identical
682
+ // sudo failures (seen on the demo tree, 2026-09-18); the caller that onboards several repos
683
+ // installs it itself, after the last one.
684
+ const daemon = opts.skipDaemonSetup
685
+ ? { ok: true }
686
+ : await watchDaemon.ensureWatchDaemon({ skipPrompts: !!opts.defaults });
646
687
 
647
688
  // The closing banner must not read as an all-clear when auto-sync is dead —
648
689
  // customers were only discovering the missing daemon after a reboot.
@@ -694,6 +735,135 @@ const CHILD_SCAN_MAX = 60; // bound the number of nested repos we resolve
694
735
  // single repo and 50 for this monorepo, versus 5 and 13 at depth 2.
695
736
  const CHILD_SCAN_MAX_DEPTH = 4;
696
737
 
738
+ /**
739
+ * Onboard every git repo found beneath a folder that is not itself a repo, into ONE project.
740
+ *
741
+ * <b>Why one project and not one per repo.</b> Cross-service answers — who calls this endpoint, what
742
+ * breaks if I change this, trace this request across services — resolve by unioning the repos of a
743
+ * PROJECT. Fourteen projects would give fourteen islands and lose the thing the customer is paying
744
+ * for. So: one project, N repos, each carrying its own origin URL and its own HEAD branch.
745
+ *
746
+ * <b>Why each repo goes through init() again rather than a bespoke path.</b> Registering a repo is
747
+ * not one call: it dedupes against the same repo in this and other projects, reuses a gitId rather
748
+ * than re-uploading, filters the manifest, pushes include/exclude patterns, scans env vars and writes
749
+ * the per-folder watch entry. `start.js` already re-enters init this way with `nested: true`. A second
750
+ * implementation of that would drift from this one within a release.
751
+ *
752
+ * Folders holding code but no repo are REPORTED, never silently skipped: from this point on, editing
753
+ * a file in one of them does nothing at all, and the user has to have been told once. Silently
754
+ * dropping code is the failure ADR-023 was written against.
755
+ */
756
+ async function onboardDiscoveredRepos({ targetDir, defaultName, childScan, opts },
757
+ runInit = init, readEntry = watchState.peek) {
758
+ const already = childScan.indexed;
759
+ const fresh = childScan.all.filter(
760
+ (r) => !already.some((c) => path.resolve(c.path) === path.resolve(r))
761
+ );
762
+ const codeFoldersWithoutRepo = nonRepoCodeFolders(targetDir, childScan.all);
763
+
764
+ console.log(`\n ${ok(`${path.basename(targetDir)} is not a git repository, but contains ${childScan.all.length} that are.`)}`);
765
+ console.log(` Niro will index each repo separately, with its own git URL and branch.\n`);
766
+ for (const r of fresh) console.log(` ${ok("+")} ${path.relative(targetDir, r) || path.basename(r)}`);
767
+
768
+ // Already indexed elsewhere: SKIPPED, not a reason to refuse the rest.
769
+ //
770
+ // The whole-run refusal further down exists because onboarding the PARENT would upload that child's
771
+ // content a second time. Here the parent is never uploaded, so that reasoning does not apply, and
772
+ // refusing thirteen new repos because the fourteenth is known would be the wrong answer.
773
+ for (const c of already) {
774
+ const where = c.projects.map((x) => `"${x.alias || x.project_id}"`).join(", ");
775
+ console.log(` ${warn("-")} ${path.relative(targetDir, c.path)} — already indexed in ${where}, skipping`);
776
+ }
777
+ for (const f of codeFoldersWithoutRepo) {
778
+ console.log(` ${warn("-")} ${path.relative(targetDir, f)} — not a git repository, NOT indexed`);
779
+ }
780
+ if (childScan.unverified.length) {
781
+ console.log(` ${warn("?")} ${childScan.unverified.length} repo(s) could not be checked against the server; they will be onboarded.`);
782
+ }
783
+ if (childScan.truncated) {
784
+ console.log(color.dim(` (Stopped after the first ${CHILD_SCAN_MAX} repos; deeper ones were not scanned.)`));
785
+ }
786
+
787
+ if (!fresh.length) {
788
+ console.log(`\n ${warn("Nothing new to index.")} Every repo here is already in Niro.`);
789
+ return;
790
+ }
791
+ if (codeFoldersWithoutRepo.length) {
792
+ console.log(color.dim(`\n Folders marked NOT indexed will not sync. Run \`niro init\` inside one to add it.`));
793
+ }
794
+
795
+ if (!opts.defaults && !opts.force && !prompt.isNonInteractive()) {
796
+ if (!(await prompt.confirm(`\n Index these ${fresh.length} repo(s)?`, true))) return;
797
+ }
798
+
799
+ // The FIRST repo creates the project; the rest attach to whatever it created. Hoisting project
800
+ // creation out of init() instead would mean duplicating the new-vs-existing resolution, the name
801
+ // availability check and the alias-to-project_id reassignment that init already does.
802
+ let projectRef = opts.addTo || null;
803
+ let onboarded = 0;
804
+ for (const repoPath of fresh) {
805
+ const name = path.relative(targetDir, repoPath) || path.basename(repoPath);
806
+ console.log(`\n ${ok("▸")} ${name}`);
807
+ const childOpts = {
808
+ ...opts,
809
+ nested: true,
810
+ // One build for the project at the end, not one per repo.
811
+ confirmBuild: "no",
812
+ // ...and one watch-daemon install for the run, done below once every repo is in.
813
+ skipDaemonSetup: true,
814
+ // The project name is the FOLDER's, not the first repo's — `apps` reads better than whichever
815
+ // repo happened to sort first.
816
+ projectName: projectRef ? undefined : (opts.projectName || defaultName),
817
+ addTo: projectRef || undefined,
818
+ };
819
+ try {
820
+ await withIndent(4, () => runInit(repoPath, childOpts));
821
+ } catch (err) {
822
+ console.error(fail(` Could not onboard ${name}: ${err.message}`));
823
+ continue;
824
+ }
825
+ const entry = readEntry(repoPath);
826
+ if (entry && entry.projectId) {
827
+ onboarded += 1;
828
+ if (!projectRef) projectRef = entry.projectId;
829
+ }
830
+ }
831
+
832
+ if (!projectRef || !onboarded) {
833
+ console.error(fail(`\n No repo could be onboarded; nothing was created.`));
834
+ process.exitCode = 1;
835
+ return;
836
+ }
837
+ const daemon = await watchDaemon.ensureWatchDaemon({ skipPrompts: !!opts.defaults });
838
+ if (!daemon.ok) {
839
+ console.log(`\n ${warn("auto-sync is OFF")} — these folders will not stay in sync (see above).`);
840
+ }
841
+ console.log(`\n ${ok(`${onboarded} repo(s) indexed into project "${projectRef}".`)}`);
842
+ console.log(` ${color.cyan(`niro build`)} to index them, or ${color.cyan("niro status")} to review first.`);
843
+ }
844
+
845
+ /**
846
+ * Immediate subfolders that hold files but are neither a git repo nor on the way to one.
847
+ *
848
+ * A grouping folder like `services/` that CONTAINS repos is not "skipped code" — its repos are being
849
+ * onboarded. Only a folder with no repo anywhere beneath it is genuinely being left out.
850
+ */
851
+ function nonRepoCodeFolders(targetDir, discoveredRepos) {
852
+ const out = [];
853
+ let entries;
854
+ try { entries = fs.readdirSync(targetDir, { withFileTypes: true }); } catch { return out; }
855
+ for (const e of entries) {
856
+ if (!e.isDirectory() || e.name.startsWith(".") || CHILD_SCAN_SKIP.has(e.name)) continue;
857
+ const dir = path.join(targetDir, e.name);
858
+ const leadsToARepo = discoveredRepos.some((r) => {
859
+ const rel = path.relative(dir, r);
860
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
861
+ });
862
+ if (!leadsToARepo) out.push(dir);
863
+ }
864
+ return out;
865
+ }
866
+
697
867
  /**
698
868
  * Collect directories under `root` that are their OWN git repo (have a `.git` entry),
699
869
  * scanning immediate children AND one level of NON-git grouping dirs (so `services/foo`,
@@ -741,7 +911,9 @@ function collectNestedGitRepos(root) {
741
911
  */
742
912
  async function scanIndexedChildren(targetDir, opts, resolve = resolveContext) {
743
913
  const { repos: gitChildren, truncated } = collectNestedGitRepos(targetDir);
744
- if (!gitChildren.length) return { indexed: [], unverified: [], truncated };
914
+ // `all` is every git repo found beneath targetDir, indexed or not. The refusal below only ever
915
+ // needed the indexed ones; onboarding them (onboardDiscoveredRepos) needs the whole list.
916
+ if (!gitChildren.length) return { indexed: [], unverified: [], truncated, all: [] };
745
917
 
746
918
  const indexed = [];
747
919
  const unverified = [];
@@ -767,7 +939,7 @@ async function scanIndexedChildren(targetDir, opts, resolve = resolveContext) {
767
939
  else unverified.push(r);
768
940
  }
769
941
  }
770
- return { indexed, unverified, truncated };
942
+ return { indexed, unverified, truncated, all: gitChildren };
771
943
  }
772
944
 
773
945
  /**
@@ -1226,4 +1398,6 @@ function logicalCwd() {
1226
1398
  return process.cwd();
1227
1399
  }
1228
1400
 
1229
- module.exports = { init, parseOnOff, parseYesNo, parseQuietPeriod, promptEnvVars, promptActivePropertyFiles, scanIndexedChildren };
1401
+ module.exports = {
1402
+ onboardDiscoveredRepos,
1403
+ nonRepoCodeFolders, init, parseOnOff, parseYesNo, parseQuietPeriod, promptEnvVars, promptActivePropertyFiles, scanIndexedChildren };
@@ -878,13 +878,42 @@ function matchesPatterns(relativePath, includePatterns, excludePatterns) {
878
878
  return !excludePatterns.some(p => minimatch(relativePath, p));
879
879
  }
880
880
 
881
- /** Simple glob matching without a dependency: handles ** and * patterns. */
881
+ // Every character that is special inside a regular expression EXCEPT `*`, which is the
882
+ // only wildcard this glob syntax has. Escaping `.` alone was the defect, because a pattern
883
+ // is not ours to choose: the server names a ROOT-LEVEL file by its own filename
884
+ // (ai-orchestrator SourceFileFilterService.derivePatterns, the `else` branch) and the new
885
+ // directory prompt above names a directory by its own path, so a real name reaches this
886
+ // function verbatim. `main.c++` at a repo root became /^main\.c++$/, "Nothing to repeat",
887
+ // and that throw killed EVERY sync pass for the folder: the file event gate, the delta and
888
+ // backstop walk, and the rescue check that runs before a delete. The quieter half of the
889
+ // same defect never threw at all: `app (copy).js` built a valid regex with a capture group,
890
+ // so the file silently stopped matching its own pattern and was never synced again.
891
+ // No /g flag: `test` on a global regex advances lastIndex between calls.
892
+ const REGEX_SPECIAL_CHARS = /[.+?^${}()|[\]\\]/;
893
+
894
+ /**
895
+ * Simple glob matching without a dependency: handles ** and * patterns.
896
+ *
897
+ * One pass over the pattern, so every character that is not a wildcard is escaped as a
898
+ * literal and the result is always a valid regular expression. The previous version
899
+ * escaped `.` and then routed `**` through a `§§` placeholder, which also corrupted any
900
+ * pattern that genuinely contained `§§`.
901
+ */
882
902
  function minimatch(filePath, pattern) {
883
- const regexStr = pattern
884
- .replace(/\./g, "\\.")
885
- .replace(/\*\*/g, "§§") // temp placeholder
886
- .replace(/\*/g, "[^/]*")
887
- .replace(/§§/g, ".*");
903
+ let regexStr = "";
904
+ for (let i = 0; i < pattern.length; i++) {
905
+ const ch = pattern[i];
906
+ if (ch === "*") {
907
+ if (pattern[i + 1] === "*") {
908
+ regexStr += ".*"; // ** crosses directory separators
909
+ i++;
910
+ } else {
911
+ regexStr += "[^/]*"; // * stays inside one path segment
912
+ }
913
+ continue;
914
+ }
915
+ regexStr += REGEX_SPECIAL_CHARS.test(ch) ? "\\" + ch : ch;
916
+ }
888
917
  return new RegExp("^" + regexStr + "$").test(filePath);
889
918
  }
890
919
 
@@ -944,8 +973,11 @@ function buildMinimalManifest(rootDir, includePatterns, excludePatterns) {
944
973
  }
945
974
 
946
975
  // handleFileEvent + deltaSyncOnResume are exported for unit testing the
947
- // gitignore/fail-closed guards without a live server or fs.watch loop.
976
+ // gitignore/fail-closed guards without a live server or fs.watch loop. matchesPatterns is
977
+ // exported for the same reason: it is the gate all three sync paths share, and a pattern that
978
+ // cannot be compiled stops every one of them, so it is worth testing on its own.
948
979
  module.exports = { watch, startWatch, startPeriodicSweep, handleFileEvent, deltaSyncOnResume, handleEntryFailure,
980
+ matchesPatterns,
949
981
  queueDelete, flushPendingDeletes, pendingDeletes, DELETE_SETTLE_MS, backstopTick, notHolding,
950
982
  unreadableRoots,
951
983
  nextBackoffMs, shouldAttemptBackstop, recordBackstopFailure, recordBackstopSuccess };
@@ -34,11 +34,25 @@ const prompt = require("./prompt");
34
34
  const color = require("./color");
35
35
 
36
36
  const BATCH_SIZE = 50;
37
+ // MUST stay identical to ai-orchestrator SourceFileFilterService.SOURCE_EXTENSIONS.
38
+ // Both are hand-maintained and nothing enforced that until test/sourceExtensionDrift.test.js
39
+ // here, which reads the Java file, and ai-orchestrator SourceExtensionDriftTest, which reads
40
+ // THIS file. The two repositories build separately, so the guard is on both sides. The
41
+ // server's /filter is the authoritative gate, so an extension missing here is never
42
+ // offered and never uploaded, whatever the server would have accepted.
37
43
  const SOURCE_EXTENSIONS = new Set([
38
44
  ".java", ".py", ".go", ".js", ".ts", ".tsx", ".jsx",
39
45
  ".cjs", ".mjs", ".cts", ".mts",
40
46
  ".kt", ".scala", ".rs", ".rb", ".php", ".cs", ".swift",
41
47
  ".c", ".cpp", ".h", ".hpp", ".dart",
48
+ // The remaining C++ extensions ast-parser routes to its C++ grammar and CppMapper.
49
+ ".cc", ".cxx", ".hxx", ".hh", ".h++", ".c++", ".tcc", ".ipp", ".inl", ".tpp",
50
+ // Ten more the parser routes to a real mapper and that this list did not offer, so the
51
+ // user's Kotlin build scripts, C# scripts, Rakefiles, gemspecs, legacy PHP files and
52
+ // Python stubs were never uploaded. Added 2026-09-14 alongside .jsx.
53
+ ".kts", ".csx", ".rake", ".gemspec",
54
+ ".phtml", ".php3", ".php4", ".php5",
55
+ ".pyi", ".pyw",
42
56
  ]);
43
57
 
44
58
  // Build/config files the server's project-structure resolver needs to detect
@@ -227,16 +227,25 @@ function ensureOnboardable(targetDir, opts = {}) {
227
227
  ));
228
228
  return true;
229
229
  }
230
+ // A folder holding git repos declares its projects with .git rather than with a build file.
231
+ // Onboarding handles that case by indexing each repo separately, so refusing here would send the
232
+ // user to --force, which uploads the whole tree as one repo instead.
233
+ if (typeof opts.hasGitReposBeneath === "function" && opts.hasGitReposBeneath()) {
234
+ return true;
235
+ }
230
236
  if (opts.force) {
231
237
  console.log(color.yellow(
232
238
  `\n ⚠ No project found in ${resolved}, but --force was passed. Onboarding it anyway.`
233
239
  ));
234
240
  return true;
235
241
  }
236
- console.error(color.red(`\n ✗ No project found in ${resolved}.`));
237
- console.error(" Niro looks for a build file (package.json, pom.xml, go.mod, ...) in this");
238
- console.error(` folder or just below it${truncated ? " (the scan stopped early — the tree is very large)" : ""}. None found.`);
239
- console.error(" Pass --force if you really mean to upload this folder.");
242
+ console.error(color.red(`\n ✗ Nothing to index in ${resolved}.`));
243
+ console.error(" Niro indexes either a git repository, or a folder holding a build file");
244
+ console.error(` (package.json, pom.xml, go.mod, ...) at it or just below it${truncated ? ", though the scan stopped early because the tree is very large" : ""}.`);
245
+ console.error(" This folder has neither, so there is nothing to build a graph from.");
246
+ console.error("");
247
+ console.error(" If the code you meant is deeper down, run niro init from that folder.");
248
+ console.error(" Pass --force to upload this folder anyway.");
240
249
  return false;
241
250
  }
242
251