@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.
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +41 -0
- package/README.md +28 -3
- package/bin/interlock +16 -5
- package/lib/graph/cli.mjs +93 -15
- package/lib/graph/extract/bicep.mjs +67 -0
- package/lib/graph/extract/create-extractor.mjs +15 -0
- package/lib/graph/extract/csharp.mjs +206 -0
- package/lib/graph/extract/csproj.mjs +42 -0
- package/lib/graph/extract/memory.mjs +9 -4
- package/lib/graph/extract/openspec.mjs +9 -5
- package/lib/graph/extract/walk.mjs +37 -12
- package/lib/graph/graph/build.mjs +77 -13
- package/lib/graph/graph/link-csharp.mjs +210 -0
- package/lib/graph/graph/query.mjs +2 -0
- package/lib/graph/graph/report.mjs +11 -1
- package/lib/graph/graph/update.mjs +56 -16
- package/lib/graph/paths.mjs +56 -4
- package/lib/graph/retrieve/context.mjs +2 -0
- package/lib/graph/retrieve/docs-index.mjs +9 -10
- package/lib/graph/retrieve/markdown.mjs +8 -8
- package/lib/limits.mjs +11 -3
- package/lib/outcomes.mjs +8 -1
- package/lib/prompts/implementer.mjs +28 -3
- package/lib/prompts/index.mjs +37 -1
- package/lib/prompts/rejected.mjs +24 -0
- package/lib/prompts/review.mjs +38 -1
- package/lib/receipt.mjs +21 -1
- package/lib/resume-card.mjs +19 -0
- package/lib/run-log.mjs +30 -2
- package/lib/run.mjs +198 -25
- package/lib/verify.mjs +32 -1
- package/lib/waves.mjs +161 -19
- package/package.json +2 -2
- package/shared/TEST-PROFILE.md +1 -1
- package/skills/bootstrap/SKILL.md +11 -4
- package/skills/fix-tests/SKILL.md +1 -0
- package/skills/graph/SKILL.md +12 -1
- package/workflows/ship.js +2 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "interlock",
|
|
3
3
|
"displayName": "Interlock",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "
|
|
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
|
[](https://github.com/renzrollon/interlock/actions/workflows/ci.yml)
|
|
7
7
|
[](docs/01-first-hour.md#before-you-start)
|
|
8
8
|
[](./LICENSE)
|
|
9
|
+
[](https://renzrollon.github.io/interlock/)
|
|
9
10
|
|
|
10
11
|
<p align="center">
|
|
11
|
-
<img src="docs/assets/
|
|
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
|
|
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
|
|
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
|
|
768
|
-
|
|
769
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
};
|