@renzrollon/interlock 1.4.2 → 1.5.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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/CHANGELOG.md +41 -0
  3. package/README.md +28 -3
  4. package/bin/interlock +16 -5
  5. package/lib/graph/cli.mjs +93 -15
  6. package/lib/graph/extract/bicep.mjs +67 -0
  7. package/lib/graph/extract/create-extractor.mjs +15 -0
  8. package/lib/graph/extract/csharp.mjs +206 -0
  9. package/lib/graph/extract/csproj.mjs +42 -0
  10. package/lib/graph/extract/memory.mjs +9 -4
  11. package/lib/graph/extract/openspec.mjs +9 -5
  12. package/lib/graph/extract/walk.mjs +37 -12
  13. package/lib/graph/graph/build.mjs +77 -13
  14. package/lib/graph/graph/link-csharp.mjs +210 -0
  15. package/lib/graph/graph/query.mjs +2 -0
  16. package/lib/graph/graph/report.mjs +11 -1
  17. package/lib/graph/graph/update.mjs +56 -16
  18. package/lib/graph/paths.mjs +56 -4
  19. package/lib/graph/retrieve/context.mjs +2 -0
  20. package/lib/graph/retrieve/docs-index.mjs +9 -10
  21. package/lib/graph/retrieve/markdown.mjs +8 -8
  22. package/lib/limits.mjs +11 -3
  23. package/lib/outcomes.mjs +8 -1
  24. package/lib/prompts/implementer.mjs +28 -3
  25. package/lib/prompts/index.mjs +37 -1
  26. package/lib/prompts/rejected.mjs +24 -0
  27. package/lib/prompts/review.mjs +38 -1
  28. package/lib/receipt.mjs +21 -1
  29. package/lib/resume-card.mjs +19 -0
  30. package/lib/run-log.mjs +30 -2
  31. package/lib/run.mjs +198 -25
  32. package/lib/verify.mjs +32 -1
  33. package/lib/waves.mjs +161 -19
  34. package/package.json +2 -2
  35. package/shared/TEST-PROFILE.md +1 -1
  36. package/skills/bootstrap/SKILL.md +11 -4
  37. package/skills/fix-tests/SKILL.md +1 -0
  38. package/skills/graph/SKILL.md +12 -1
  39. package/workflows/ship.js +2 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "interlock",
3
3
  "displayName": "Interlock",
4
- "version": "1.4.2",
5
- "description": "Autonomous spec-driven development orchestration for Claude Code, layered on OpenSpec. Onboard a repo, produce a reviewed spec, then ship it start-to-commit with parallel agents and a deterministic gate spine.",
4
+ "version": "1.5.0",
5
+ "description": "Spec-driven development for Claude Code, layered on OpenSpec. You read one spec; parallel agents ship it to a tested commit.",
6
6
  "author": {
7
7
  "name": "Carl Rollon",
8
8
  "url": "https://github.com/renzrollon"
package/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to this project are documented here.
5
5
  The format follows Keep a Changelog 1.1.0, and
6
6
  this project adheres to Semantic Versioning 2.0.0.
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## 1.5.0 — 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - **The graph indexes C# and Bicep (#8).** `.cs`, `.csproj` and `.bicep` files were skipped, so a .NET repository got docs and OpenSpec links and nothing structural.
15
+ - **Project references** (`<ProjectReference>`, either slash style) are `EXTRACTED` project-to-project edges. A reference that holds a `$(...)` property or names no file stays unresolved rather than guessed.
16
+ - **Types** (`class`, `record`, `struct`, `interface`, `enum`) are symbols with their line. A namespace is one `namespace:` node, with a `declares` edge from each file that declares it; a `using` resolves to it, or to an external name when the repository declares none.
17
+ - **References and tests are `INFERRED`.** A file that names a type from a namespace it can see gets a `references` edge. A method under `Fact`, `Theory`, `Test`, `TestCase`, `TestMethod` or `DataTestMethod` is a test node, and `consumers` prints a `consumer_test:` line for it.
18
+ - **Bicep** modules are import edges, `param`, `var`, `resource` and `output` are symbols, and a `<module>.outputs.<x>` use is an `INFERRED` reference.
19
+ - **Build output is not indexed.** A `bin/` or `obj/` beside a `.csproj` is skipped; one without a project file beside it, such as `bin/cli.js`, is still indexed.
20
+ - **Known limits**, stated in `skills/graph/SKILL.md`: a fully-qualified name with no `using` is not seen, `global using` applies to its own file, a nested type has no record of its nesting, and a declaration-shaped line inside a verbatim or raw string is a false positive.
21
+ - **The empty-graph notice** lists the new extensions.
22
+
23
+ ### Changed
24
+
25
+ - **`interlock-graph` finishes when it meets a path it cannot read (#7).** One unreadable `.mcp.json` made `build` exit 1 with a stack trace and no graph, and `update` and `docs-index` skipped unreadable files without a word.
26
+ - **Each skipped path is named** on stderr as `GRAPH SKIPPED UNREADABLE: <path> (<code>)`, once. An unreadable `.gitignore` or `.interlockignore` says its patterns were not applied.
27
+ - **The command exits 0.** The `build` and `update` summaries end in `skipped=<n>` only when something was skipped, `manifest.json` records `skipped`, and `GRAPH_REPORT.md` lists the paths under "Not indexed". `update` rebuilds when the set of unreadable paths changes, and a manifest from an older build reads as having none.
28
+ - **A query says when its graph is partial.** `query`, `consumers`, `path`, `explain` and `context` print one `GRAPH PARTIAL` line on stderr when the graph they read was built past skipped paths. Their stdout and exit code do not change, and a graph built with nothing skipped prints nothing new.
29
+ - **A script that read the exit code as "the graph is complete" must read `skipped=<n>` as well.** `/interlock:bootstrap` now does: it prints `GRAPH PARTIAL` for a graph built past unreadable paths, and tells an unreadable tree apart from an unsupported language.
30
+ - **A file the graph holds a node for is a file it read.** The hash and the node now come from one read.
31
+ - **The inter-wave verify budget is derived from the test profile (#10).** It was one published constant, so a suite slower than the constant was never planned at the second checkpoint and a ship run silently stopped verifying between waves.
32
+ - **At `run start` the budget is derived from `unit.timeout_ms`** and floored at the published budget, then fixed for the run; editing the profile mid-run changes nothing for that run. The `interlock limits` row is relabelled `inter-wave verify budget (floor)`; its value and its `--json` key are unchanged.
33
+ - **`VERIFY BUDGET AT THE PUBLISHED FLOOR`** is raised once, when a checkpoint is cut short — to nothing, or to typecheck alone when the profile names a typecheck command — and the profile carried no `unit.timeout_ms`. It names `/interlock:fix-tests --reconfigure`.
34
+ - **`interlock verify plan --context inter-wave --json`** applies the same budget and prints `budgetSource` (`profile`, `published` or `flag`).
35
+ - **`/interlock:fix-tests`** records `unit.timeout_ms` as a green full run's wall time in whole milliseconds, rounded up, and never writes a value below one it measured.
36
+ - **The run's receipt and close summary carry `verifyBudget`.**
37
+
38
+ ### Fixed
39
+
40
+ - **A handoff packet that fails the schema no longer fails a task that reported success, or halts the run (#9).** Three finished tasks whose packets were over the character cap, had an evidence range, or an empty `next` counted as three failures and stopped a run on work that was done and ticked by hand afterwards.
41
+ - **Breaking, for the recorded outcome.** Such a task is now recorded `ok` with a `reportRejected` reason, its box is ticked, and it no longer counts toward the task-failure halt. A reader or script that took a rejected packet to be a failed task now sees an `ok` outcome. Look for `HANDOFF REJECTED` in the batch's banners, a `handoff reports rejected` row in the run's summary, `reportRejected` on the receipt's wave, and `report(s) rejected` in `interlock run-log show`. Outcome rows and the resume card carry the ids too.
42
+ - **`CLAIM OVERRIDDEN` no longer fires from a real run.** A rejected packet was the only way `record-batch` recorded a claimed success as a failure. The claim and the record now agree for that input, so nothing is overridden; the adjudication stays and is pinned by its unit cases.
43
+ - **The packet is still never kept or trimmed.** The next wave's briefing gets a `NO VALIDATED REPORT` block naming the task, the reason and the paths it reported, and not its summary. The reason is a short single line that quotes at most a clipped piece of the offending value, and the paths are listed only up to the handoff character cap with the rest counted, so a rejected report cannot hand on more than a valid packet may.
44
+ - **The last wave says so.** With no later wave to brief, `HANDOFF REJECTED` no longer ends in "the next wave is told": it names what does check that work — the review under `--strict` or `--review`, final verification otherwise — or says that nothing does under `--apply-only`. The review briefing carries an `UNVALIDATED REPORTS` block naming every rejected report of the run. A rejected report on the last wave still does not block the commit.
45
+ - **The implementer briefing no longer says an over-cap packet "fails the task".** It says the packet is rejected whole and the task is recorded as having filed no usable report. Every implementer prompt changes by that sentence, so a Workflow run resumed across this upgrade re-runs lanes it had already finished.
46
+ - **A task that reports `ok: false`, or never reports, fails and counts exactly as before**, and keeps its own error.
47
+ - **This change did not touch** checkpoint scoping, the re-ask, or checkpoint placement. Where the inter-wave checkpoints fall and what each re-checks are as they were.
48
+
8
49
  ## 1.4.2 — 2026-10-10
9
50
 
10
51
  ### Fixed
package/README.md CHANGED
@@ -6,10 +6,16 @@
6
6
  [![CI](https://github.com/renzrollon/interlock/actions/workflows/ci.yml/badge.svg)](https://github.com/renzrollon/interlock/actions/workflows/ci.yml)
7
7
  [![Claude Code ≥ 2.1.154](https://img.shields.io/badge/Claude%20Code-%E2%89%A5%202.1.154-blue)](docs/01-first-hour.md#before-you-start)
8
8
  [![MIT](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
9
+ [![Site](https://img.shields.io/badge/site-renzrollon.github.io%2Finterlock-ff563c)](https://renzrollon.github.io/interlock/)
9
10
 
10
11
  <p align="center">
11
- <img src="docs/assets/interlock-flow-wide.png" alt="Interlock flow: bootstrap once, then spec, you read the spec, ship, and optionally mr. Two commands, one human checkpoint." width="900">
12
+ <img src="docs/assets/spec-and-ship.gif" alt="One real run, 34 seconds: /interlock:spec asks one question, writes the change and stops at the checkpoint; /interlock:ship runs two waves of parallel agents, the suite passes 180 of 180, one commit." width="838">
12
13
  </p>
14
+ <p align="center"><sub>One real run, cut to 34 s. <code>spec</code> asks one question, writes 11 specs and 12 tasks, then stops. <code>ship</code> runs three agents in parallel across two waves and ends in a commit with the suite green, 180 of 180.</sub></p>
15
+ <p align="center"><code>12 tasks · 2 waves · 19 agents · 13m 54s · 57 files · 2,881 lines · 180 of 180 pass · 1 commit</code></p>
16
+ <!-- The uncut, captioned 2:34 of the same run is not in git. Once it is a release asset, uncomment:
17
+ <p align="center"><sub>The whole run, uncut and captioned: <a href="https://github.com/renzrollon/interlock/releases/download/v1.4.2/interlock-spec-and-ship-full.mp4">2:34, mp4, 9 MB</a>.</sub></p>
18
+ -->
13
19
 
14
20
  ```bash
15
21
  /interlock:spec "<idea>" # explores your repo, writes a reviewed spec, then stops
@@ -54,11 +60,22 @@ interlock doctor # preflight: prints the exact allowlist an unattended
54
60
  | `/interlock:ship` | Reviewed change → waves (parallel batches of file-disjoint tasks) → your test suite → commit. No review by default; `--strict` adds adversarial review and handoff artifacts. `--solo` / `--waves` force the shape | **workflow** |
55
61
  | `/interlock:mr` | Change → merge request | skill |
56
62
 
63
+ <p align="center">
64
+ <img src="docs/assets/interlock-flow-wide.png" alt="Interlock flow: bootstrap once, then spec, you read the spec, ship, and optionally mr. Two commands, one human checkpoint." width="900">
65
+ </p>
66
+
57
67
  `ship` is the odd one out on purpose. A skill is instructions Claude follows; a workflow is a script a runtime executes, and this runtime has no channel for a question. That is why every decision that could need a human is settled *before* ship starts, and why `spec` is conversational where it has to be. The plan preview names the shape and the agent count before anything is spawned.
58
68
 
69
+ <p align="center">
70
+ <img src="docs/assets/ship-meter.png" alt="The ship meter pane during the recorded run: wave 1 with three lanes marked ok and the model that served each, the verify checkpoint between the waves, wave 2 with two lanes running and one pending, then verify-final, commit and close." width="760">
71
+ </p>
72
+ <p align="center"><sub>The same run, drawn inside Claude Code while it ran: every wave, batch and lane as the CLI reported them.</sub></p>
73
+
74
+ **You can watch it.** On Claude Code 2.1.287 and later the plugin's hooks module draws the run as it goes: a status line that names the stage and stops at the checkpoint, a pane with every wave, lane and agent, and each degradation banner as a toast the moment the step that raised it prints. The pane observes; the CLI decides. On an older host the run is the same, without the pane. [The ship meter →](docs/07-cli-and-configuration.md#the-ship-meter-interlock-meter)
75
+
59
76
  ## Why this and not a folder of prompts
60
77
 
61
- Decisions that have a correct answer are moved out of prose and into code, one at a time. The script holds the loop, the CLI holds the rules, the agents do the work.
78
+ Decisions that have a correct answer are moved out of prose and into code, one at a time. The script holds the loop, the CLI holds the rules, the agents do the work. Interlock is built with Interlock: the features and fixes here start as OpenSpec changes and go through the same spec, checkpoint and ship, and [openspec/changes/archive](openspec/changes/archive) is the record, one folder per change.
62
79
 
63
80
  - **Thresholds are code.** Wave order, the parallel-agent cap, the remediation round budget, the review quality band: each is an `interlock` subcommand with an exit code, not markdown a model can talk itself past. [Every subcommand →](docs/07-cli-and-configuration.md)
64
81
  - **Parallel agents cannot overwrite each other.** The planner separates tasks that would touch the same file; with `--isolate-waves`, every parallel task runs in its own git worktree and a collision is a named halt, never a lost write.
@@ -71,6 +88,14 @@ Decisions that have a correct answer are moved out of prose and into code, one a
71
88
 
72
89
  Most of the category competes on how much structure you write before coding — Spec Kit adds phases, BMAD adds roles, Kiro adds an IDE. Interlock competes on a different axis: **how many decisions the model is not allowed to make.**
73
90
 
91
+ | | Competes on | Where the rules live |
92
+ |---|---|---|
93
+ | Spec Kit | phases you write before code | prompt templates, portable across thirty agents |
94
+ | BMAD | roles you assign | prompt templates |
95
+ | Kiro | an IDE with specs built in | the IDE |
96
+ | OpenSpec | the artifact loop and its CLI | the CLI validates artifacts; skills run the loop |
97
+ | **Interlock** | how many decisions the model may not make | a CLI with exit codes, and a runtime with no channel for a question |
98
+
74
99
  It composes OpenSpec rather than replacing it. `openspec init` installs its own skills, and `/interlock:spec` drives the `openspec` CLI directly, so both stay available. Use `/interlock:spec` when you want the gates, and the stock skills when you want the plain artifact loop. What Interlock adds, and when plain OpenSpec is the right call: [03](docs/03-openspec-vs-interlock.md). Where it sits against OpenClaw, Hermes Agent and DeepSeek Harness: [08](docs/08-harness-landscape.md).
75
100
 
76
101
  The trade is portability. Claude Code is the **default and supported host**; Cursor and Copilot are not supported. Everything the loop decides lives in a CLI any host can shell out to, so portability means a second host adapter, not thirty prompt templates.
@@ -79,7 +104,7 @@ The trade is portability. Claude Code is the **default and supported host**; Cur
79
104
 
80
105
  `interlock-run` drives the same loop over the Claude Code CLI, any [Agent Client Protocol (ACP)](https://agentclientprotocol.com) agent, OpenAI Codex or Qwen Code. You start it yourself. No slash command does, and `/interlock:ship` never falls back to it.
81
106
 
82
- Two things to know before you do. `claude -p`, the Agent SDK and ACP are the usage Anthropic flagged for separate metered credit; the interactive Workflow runtime that `/interlock:ship` uses is the exempted path, and the runner prints which path it is on. And Codex and Qwen do not understand the planner's model tiers, so map them per host with `INTERLOCK_MODEL_MAP`; an unmapped tier is bannered, never silently run on your default.
107
+ Two caveats, both bannered by the runner. `claude -p`, the Agent SDK and ACP are the usage Anthropic flagged for separate metered credit, while the Workflow runtime behind `/interlock:ship` is the exempted path. And Codex and Qwen need the planner's model tiers mapped with `INTERLOCK_MODEL_MAP`; an unmapped tier is bannered, never silently run on your default.
83
108
 
84
109
  Host table, banners and configuration: [07](docs/07-cli-and-configuration.md#interlock-run--the-experimental-runner). The older `interlock-ship-acp` still works, prints a deprecation line, and is removed in the next minor. Code Mode is out of scope.
85
110
 
package/bin/interlock CHANGED
@@ -104,6 +104,7 @@ import {
104
104
  import { writeReviewMetrics } from '../lib/metrics.mjs'
105
105
  import {
106
106
  planVerification,
107
+ interWaveVerifyBudget,
107
108
  judgeVerification,
108
109
  judgeUnitResult,
109
110
  clusterFailures,
@@ -652,7 +653,8 @@ verify plan:
652
653
  --no-lint Skip lint
653
654
  --pre-existing The suite was already red before this change
654
655
  --elapsed-ms <n> Verification time already spent this run
655
- --budget-ms <n> Soft budget (default ${LIMITS.interWaveVerifyBudgetMs}ms); past it, typecheck only
656
+ --budget-ms <n> Soft budget; past it, typecheck only. The inter-wave default is derived
657
+ from the profile and floored at the published budget
656
658
  --context <ctx> ${Object.keys(VERIFY_CONTEXTS).join('|')}. inter-wave omits e2e and
657
659
  coverage by default (typecheck, unit, lint only).
658
660
  --changed <files...> Repo-relative paths the last wave touched. When every
@@ -764,9 +766,12 @@ wave-state:
764
766
  handoff: { schema: "interlock.wave-handoff/1", taskId,
765
767
  status: ok|blocked|partial, summary, evidence[], next,
766
768
  blocker }. A task claiming ok:true without a valid,
767
- in-budget packet is recorded as failed ("invalid
768
- handoff: ..."); see "interlock limits" for the cap.
769
- The next wave's step carries these as previousHandoffs.
769
+ in-budget packet stays recorded as done; the packet
770
+ is rejected, never stored or trimmed, and the outcome
771
+ carries reportRejected with the reason. See
772
+ "interlock limits" for the cap. The next wave's step
773
+ carries valid packets as previousHandoffs and rejected
774
+ ones as previousRejectedReports.
770
775
  record-verify --state <f> --result <f> result: { ok, errors?, blocksNextWave? } or
771
776
  { skipped: true, reason }
772
777
  replan --state <f> --groups <f> groups: [{ group, tasks: [...] }]
@@ -2575,6 +2580,7 @@ async function main() {
2575
2580
  if (elapsedMs !== undefined) opts.elapsedMs = elapsedMs
2576
2581
  const budgetMs = num(flags, 'budget-ms')
2577
2582
  if (budgetMs !== undefined) opts.budgetMs = budgetMs
2583
+ let budgetSource = budgetMs !== undefined ? 'flag' : 'published'
2578
2584
  const context = flags.context === undefined ? undefined : String(flags.context)
2579
2585
  if (context !== undefined) {
2580
2586
  if (!Object.prototype.hasOwnProperty.call(VERIFY_CONTEXTS, context)) {
@@ -2584,12 +2590,17 @@ async function main() {
2584
2590
  }
2585
2591
  opts.context = context
2586
2592
  if (context === 'inter-wave') opts.coverage = false
2593
+ if (context === 'inter-wave' && profile && budgetMs === undefined) {
2594
+ const derived = interWaveVerifyBudget(profile)
2595
+ opts.budgetMs = derived.ms
2596
+ budgetSource = derived.source
2597
+ }
2587
2598
  }
2588
2599
  if (flags.changed !== undefined) {
2589
2600
  opts.changed = Array.isArray(flags.changed) ? flags.changed : splitPaths(flags.changed)
2590
2601
  }
2591
2602
 
2592
- const plan = planVerification(profile, opts)
2603
+ const plan = { ...planVerification(profile, opts), budgetSource }
2593
2604
  emit(json, plan, formatVerification(plan))
2594
2605
  } else if (sub === 'judge') {
2595
2606
  const plan = readInput(flags.plan, '--plan <file|->')
package/lib/graph/cli.mjs CHANGED
@@ -13,7 +13,16 @@ import {
13
13
  writeDocsIndex,
14
14
  } from './retrieve/docs-index.mjs';
15
15
  import { formatDocsResult, retrieveMarkdownSections } from './retrieve/markdown.mjs';
16
- import { graphPath, reportPath, resolveRoot } from './paths.mjs';
16
+ import {
17
+ createSkips,
18
+ formatSkip,
19
+ graphPath,
20
+ loadJson,
21
+ manifestPath,
22
+ PARTIAL_MARKER,
23
+ reportPath,
24
+ resolveRoot,
25
+ } from './paths.mjs';
17
26
  import { resolveStateHome } from '../state-home.mjs';
18
27
 
19
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -78,6 +87,51 @@ function graphRootFor(root) {
78
87
  return root;
79
88
  }
80
89
 
90
+ /**
91
+ * The root a query command reads its graph from, having said — once, on stderr —
92
+ * when that graph was built past paths it could not read (graph/unreadable-paths).
93
+ *
94
+ * The build named each skipped path when it skipped it. A query runs later, often
95
+ * in another session, and "no consumers" from a graph that never read the file is
96
+ * not evidence the file is unused. The count comes from the manifest beside the
97
+ * graph that is actually loaded, so a query served from the main checkout reports
98
+ * that graph's skips. A graph built with none, by a version that recorded none, or
99
+ * beside a manifest that cannot be read prints nothing: the query answers as it
100
+ * always did, and stdout and the exit code never move.
101
+ */
102
+ function queryGraphRoot(root) {
103
+ const from = graphRootFor(root);
104
+ let recorded;
105
+ try {
106
+ recorded = loadJson(manifestPath(from), null)?.skipped;
107
+ } catch {
108
+ recorded = null;
109
+ }
110
+ if (Array.isArray(recorded) && recorded.length > 0) {
111
+ process.stderr.write(
112
+ `${PARTIAL_MARKER}: ${recorded.length} path(s) were unreadable when this graph was built and ` +
113
+ `are not in it (GRAPH_REPORT.md lists them under "Not indexed") — no result for one of ` +
114
+ `them is not evidence it is unused; interlock-graph update re-reads them\n`,
115
+ );
116
+ }
117
+ return from;
118
+ }
119
+
120
+ /**
121
+ * Name every path the command could not read, one line each on stderr, after
122
+ * the command's stdout (graph/unreadable-paths). A reader never prints; the
123
+ * command does, here, so a skip is said once and the exit code does not move.
124
+ */
125
+ function speakSkips(skips) {
126
+ for (const entry of skips.list()) process.stderr.write(`${formatSkip(entry)}\n`);
127
+ }
128
+
129
+ /** ` skipped=<n>` for a summary line, and nothing at all on a clean tree. */
130
+ function skippedField(skips) {
131
+ const n = skips.list().length;
132
+ return n > 0 ? ` skipped=${n}` : '';
133
+ }
134
+
81
135
  function parseArgs(argv) {
82
136
  const args = {
83
137
  _: [],
@@ -142,19 +196,27 @@ function main() {
142
196
  break;
143
197
  }
144
198
  case 'build': {
145
- const result = buildGraph(root);
199
+ const skips = createSkips(root);
200
+ const result = buildGraph(root, { skips });
146
201
  const written = writeGraphArtifacts(root, result);
147
202
  process.stdout.write(
148
203
  `Built graph → ${written.graphPath}\nReport → ${written.reportPath}\n` +
149
- `files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges} extractor=${result.stats.extractor}\n`,
204
+ `files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges} extractor=${result.stats.extractor}${skippedField(skips)}\n`,
150
205
  );
151
206
  // An empty graph is a legitimate outcome — this repo is written in a
152
207
  // language the walker does not structurally index. Saying so is the
153
- // difference between "unsupported" and "broken".
154
- if (result.stats.files === 0) {
208
+ // difference between "unsupported" and "broken". An empty graph beside
209
+ // skipped paths is a third thing: blaming the language there would
210
+ // misname a permissions problem.
211
+ if (result.stats.files === 0 && skips.list().length > 0) {
212
+ process.stdout.write(
213
+ '\nNo source files were indexed: nothing indexable under the root could be read ' +
214
+ '(each skipped path is named on stderr).\n',
215
+ );
216
+ } else if (result.stats.files === 0) {
155
217
  process.stdout.write(
156
218
  '\nNo source files were indexed. Structural indexing covers ' +
157
- '.ts .tsx .js .jsx .mjs .cjs .py .pyi .sh .bash\n' +
219
+ '.ts .tsx .js .jsx .mjs .cjs .py .pyi .sh .bash .cs .csproj .bicep\n' +
158
220
  'plus curated config JSON. Other languages (Go, Rust, Java, Ruby) ' +
159
221
  'are not structurally indexed yet:\n' +
160
222
  'docs, OpenSpec specs and spec→file links still work, but there ' +
@@ -163,18 +225,22 @@ function main() {
163
225
  'prose retrieval on this repo.\n',
164
226
  );
165
227
  }
228
+ speakSkips(skips);
166
229
  break;
167
230
  }
168
231
  case 'update': {
169
- const result = updateGraph(root);
232
+ const skips = createSkips(root);
233
+ const result = updateGraph(root, { skips });
170
234
  if (!result.updated) {
171
- process.stdout.write(`Graph up to date (${result.reason})\n`);
235
+ process.stdout.write(`Graph up to date (${result.reason})${skippedField(skips)}\n`);
172
236
  } else {
173
237
  process.stdout.write(
174
238
  `Updated graph (${result.reason})\n` +
175
- `files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges}\n`,
239
+ `files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges}${skippedField(skips)}\n`,
176
240
  );
177
241
  }
242
+ // Said whether or not it rebuilt: a path still unreadable is still not indexed.
243
+ speakSkips(skips);
178
244
  break;
179
245
  }
180
246
  case 'report': {
@@ -187,7 +253,10 @@ function main() {
187
253
  modules: graph.nodes.filter((n) => n.type === 'module').length,
188
254
  extractor: graph.graph?.extractor,
189
255
  };
190
- const report = writeReport(root, graph, stats);
256
+ // Regenerated from the graph, which holds no skips: the manifest is
257
+ // their record, so the section survives a re-render.
258
+ const recorded = loadJson(manifestPath(root), null)?.skipped;
259
+ const report = writeReport(root, graph, stats, Array.isArray(recorded) ? recorded : []);
191
260
  fs.writeFileSync(reportPath(root), report);
192
261
  process.stdout.write(report);
193
262
  break;
@@ -195,7 +264,7 @@ function main() {
195
264
  case 'query': {
196
265
  const q = args._.slice(1).join(' ');
197
266
  if (!q) throw new Error('query requires a search string');
198
- const from = graphRootFor(root);
267
+ const from = queryGraphRoot(root);
199
268
  const graph = loadGraph(from);
200
269
  const seeds = findSeedNodes(graph, q);
201
270
  if (!seeds.length) {
@@ -214,32 +283,35 @@ function main() {
214
283
  case 'consumers': {
215
284
  const target = args._.slice(1).join(' ');
216
285
  if (!target) throw new Error('consumers requires a symbol or file');
217
- process.stdout.write(consumers(loadGraph(graphRootFor(root)), target));
286
+ process.stdout.write(consumers(loadGraph(queryGraphRoot(root)), target));
218
287
  break;
219
288
  }
220
289
  case 'path': {
221
290
  const a = args._[1];
222
291
  const b = args._[2];
223
292
  if (!a || !b) throw new Error('path requires two endpoints');
224
- process.stdout.write(shortestPath(loadGraph(graphRootFor(root)), a, b));
293
+ process.stdout.write(shortestPath(loadGraph(queryGraphRoot(root)), a, b));
225
294
  break;
226
295
  }
227
296
  case 'explain': {
228
297
  const q = args._.slice(1).join(' ');
229
298
  if (!q) throw new Error('explain requires a node query');
230
- process.stdout.write(explain(loadGraph(graphRootFor(root)), q));
299
+ process.stdout.write(explain(loadGraph(queryGraphRoot(root)), q));
231
300
  break;
232
301
  }
233
302
  case 'docs': {
234
303
  const q = args._.slice(1).join(' ');
235
304
  if (!q && args.changed.length === 0) throw new Error('docs requires a query or --changed paths');
305
+ const skips = createSkips(root);
236
306
  const result = retrieveMarkdownSections(root, {
237
307
  query: q,
238
308
  changedFiles: args.changed,
239
309
  dirs: args.dirs ?? undefined,
240
310
  budget: defaultBudget('docs', args),
311
+ skips,
241
312
  });
242
313
  process.stdout.write(formatDocsResult(result));
314
+ speakSkips(skips);
243
315
  if (result.empty) process.exit(1);
244
316
  break;
245
317
  }
@@ -248,8 +320,10 @@ function main() {
248
320
  if (!q && args.changed.length === 0 && !args.commitSubject) {
249
321
  throw new Error('context requires a query, --changed paths, or --commit-subject');
250
322
  }
323
+ const skips = createSkips(root);
251
324
  const result = retrieveContext(root, {
252
- graphRoot: graphRootFor(root),
325
+ skips,
326
+ graphRoot: queryGraphRoot(root),
253
327
  query: q,
254
328
  changedFiles: args.changed,
255
329
  commitSubject: args.commitSubject,
@@ -259,13 +333,16 @@ function main() {
259
333
  docsDirs: args.dirs ?? undefined,
260
334
  });
261
335
  process.stdout.write(formatContextResult(result));
336
+ speakSkips(skips);
262
337
  if (result.empty) process.exit(1);
263
338
  break;
264
339
  }
265
340
  case 'docs-index': {
266
341
  const write = args.write !== false;
342
+ const skips = createSkips(root);
267
343
  const { index, path: outPath, written } = writeDocsIndex(root, {
268
344
  write,
345
+ skips,
269
346
  // Omit rather than defaulting, so buildDocsIndex can probe for the
270
347
  // docs root this repo actually uses (doc/, site/, root README, …).
271
348
  dirs: args.dirs ?? undefined,
@@ -274,6 +351,7 @@ function main() {
274
351
  if (!written) {
275
352
  process.stdout.write(`${JSON.stringify(index, null, 2)}\n`);
276
353
  }
354
+ speakSkips(skips);
277
355
  break;
278
356
  }
279
357
  default:
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Regex-based Bicep extractor: module imports, param/var/resource/output
3
+ * declarations, and `<module>.outputs.<x>` references.
4
+ */
5
+
6
+ const MODULE_RE = /^[ \t]*module[ \t]+([A-Za-z_]\w*)[ \t]+'([^'\n]+)'[ \t]*=/;
7
+ const DECL_RE = /^[ \t]*(param|var|resource|output)[ \t]+([A-Za-z_]\w*)\b/;
8
+ const COMMENT_LINE_RE = /^[ \t]*(?:\/\/|\/\*|\*)/;
9
+
10
+ /**
11
+ * @param {string} filePath relative posix path
12
+ * @param {string} source
13
+ */
14
+ export function extractBicep(filePath, source) {
15
+ const imports = [];
16
+ const exports = [];
17
+ const lines = source.split(/\r?\n/);
18
+ const modules = new Map(); // module symbolic name -> target as written
19
+
20
+ for (let i = 0; i < lines.length; i += 1) {
21
+ const text = lines[i];
22
+ if (COMMENT_LINE_RE.test(text)) continue;
23
+ const line = i + 1;
24
+ let m = MODULE_RE.exec(text);
25
+ if (m) {
26
+ modules.set(m[1], m[2]);
27
+ imports.push({
28
+ from: m[2],
29
+ imported: null,
30
+ local: m[1],
31
+ line,
32
+ confidence: 'EXTRACTED',
33
+ });
34
+ continue;
35
+ }
36
+ m = DECL_RE.exec(text);
37
+ if (m) {
38
+ exports.push({ name: m[2], kind: m[1], line, confidence: 'EXTRACTED' });
39
+ }
40
+ }
41
+
42
+ if (modules.size > 0) {
43
+ const seen = new Set();
44
+ for (let i = 0; i < lines.length; i += 1) {
45
+ const text = lines[i];
46
+ if (COMMENT_LINE_RE.test(text)) continue;
47
+ for (const [name, target] of modules) {
48
+ const re = new RegExp(`(?<![\\w.])${name}(?:\\[[^\\]]*\\])?\\??\\.outputs\\.([A-Za-z_]\\w*)`, 'g');
49
+ let m;
50
+ while ((m = re.exec(text))) {
51
+ const key = `${name}\0${m[1]}`;
52
+ if (seen.has(key)) continue;
53
+ seen.add(key);
54
+ imports.push({
55
+ from: target,
56
+ imported: m[1],
57
+ local: null,
58
+ line: i + 1,
59
+ confidence: 'INFERRED',
60
+ });
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return { filePath, imports, exports, references: [] };
67
+ }
@@ -3,6 +3,9 @@ import { createJsExtractor } from './typescript.mjs';
3
3
  import { extractPython } from './python.mjs';
4
4
  import { extractShell } from './shell.mjs';
5
5
  import { extractConfigJson } from './config-json.mjs';
6
+ import { extractCsproj } from './csproj.mjs';
7
+ import { extractCsharp } from './csharp.mjs';
8
+ import { extractBicep } from './bicep.mjs';
6
9
 
7
10
  /**
8
11
  * Language-dispatching extractor. JS/TS uses typescript AST when available.
@@ -29,6 +32,18 @@ export function createExtractor(root) {
29
32
  modes.add('json');
30
33
  return extractConfigJson(filePath, source);
31
34
  }
35
+ if (ext === '.csproj') {
36
+ modes.add('csproj');
37
+ return extractCsproj(filePath, source);
38
+ }
39
+ if (ext === '.cs') {
40
+ modes.add('csharp');
41
+ return extractCsharp(filePath, source);
42
+ }
43
+ if (ext === '.bicep') {
44
+ modes.add('bicep');
45
+ return extractBicep(filePath, source);
46
+ }
32
47
  return js.extract(filePath, source);
33
48
  },
34
49
  };