@interf/compiler 0.33.0 → 0.50.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/README.md +122 -226
- package/dist/cli/commands/agents.js +1 -32
- package/dist/cli/commands/benchmark.d.ts +2 -3
- package/dist/cli/commands/benchmark.js +1 -31
- package/dist/cli/commands/build-plan.js +26 -50
- package/dist/cli/commands/build.d.ts +2 -3
- package/dist/cli/commands/build.js +1 -31
- package/dist/cli/commands/graphs.js +177 -32
- package/dist/cli/commands/mcp.d.ts +1 -0
- package/dist/cli/commands/mcp.js +223 -126
- package/dist/cli/commands/project.js +10 -36
- package/dist/cli/commands/reset.d.ts +2 -3
- package/dist/cli/commands/reset.js +1 -22
- package/dist/cli/commands/runs.js +86 -33
- package/dist/cli/commands/status.js +3 -24
- package/dist/cli/commands/traces.js +1 -29
- package/dist/cli/commands/wizard.js +17 -29
- package/dist/cli/lib/http-client.d.ts +39 -0
- package/dist/cli/lib/http-client.js +73 -0
- package/dist/packages/build-plans/authoring/brief.d.ts +25 -4
- package/dist/packages/build-plans/authoring/build-plan-authoring.d.ts +42 -1
- package/dist/packages/build-plans/authoring/build-plan-authoring.js +470 -63
- package/dist/packages/build-plans/authoring/build-plan-edit-session.d.ts +9 -0
- package/dist/packages/build-plans/authoring/build-plan-edit-session.js +27 -10
- package/dist/packages/build-plans/authoring/build-plan-improvement.js +62 -8
- package/dist/packages/build-plans/authoring/lib/build-plan-edit-utils.d.ts +1 -0
- package/dist/packages/build-plans/package/build-plan-definitions.d.ts +0 -1
- package/dist/packages/build-plans/package/build-plan-definitions.js +5 -3
- package/dist/packages/build-plans/package/build-plan-stage-runner.d.ts +1 -0
- package/dist/packages/build-plans/package/build-plan-stage-runner.js +2 -1
- package/dist/packages/build-plans/package/builtin-build-plan.d.ts +2 -2
- package/dist/packages/build-plans/package/builtin-build-plan.js +3 -3
- package/dist/packages/build-plans/package/context-interface.d.ts +3 -0
- package/dist/packages/build-plans/package/context-interface.js +5 -5
- package/dist/packages/build-plans/package/interf-build-plan-package.js +22 -22
- package/dist/packages/build-plans/package/local-build-plans.d.ts +10 -5
- package/dist/packages/build-plans/package/local-build-plans.js +57 -32
- package/dist/packages/contracts/index.d.ts +4 -3
- package/dist/packages/contracts/index.js +2 -1
- package/dist/packages/contracts/lib/context-graph-layer.d.ts +161 -0
- package/dist/packages/contracts/lib/context-graph-layer.js +216 -0
- package/dist/packages/contracts/lib/project-paths.d.ts +7 -0
- package/dist/packages/contracts/lib/project-paths.js +9 -0
- package/dist/packages/contracts/lib/project-schema.d.ts +264 -1
- package/dist/packages/contracts/lib/project-schema.js +38 -13
- package/dist/packages/contracts/lib/schema.d.ts +556 -23
- package/dist/packages/contracts/lib/schema.js +279 -18
- package/dist/packages/contracts/utils/filesystem.d.ts +1 -0
- package/dist/packages/contracts/utils/filesystem.js +29 -1
- package/dist/packages/projects/lib/schema.d.ts +6 -8
- package/dist/packages/projects/lib/schema.js +3 -1
- package/dist/packages/projects/source-config.d.ts +0 -5
- package/dist/packages/projects/source-config.js +9 -22
- package/dist/packages/runtime/actions/fields.d.ts +4 -0
- package/dist/packages/runtime/actions/form-builders.js +79 -31
- package/dist/packages/runtime/actions/form-validators.js +9 -3
- package/dist/packages/runtime/actions/helpers.js +3 -3
- package/dist/packages/runtime/actions/registry.d.ts +1 -1
- package/dist/packages/runtime/actions/registry.js +1 -1
- package/dist/packages/runtime/actions/requests.d.ts +1 -1
- package/dist/packages/runtime/actions/requests.js +12 -6
- package/dist/packages/runtime/actions/schemas.d.ts +7 -0
- package/dist/packages/runtime/actions/schemas.js +1 -0
- package/dist/packages/runtime/agent-handoff.js +8 -7
- package/dist/packages/runtime/agents/lib/execution-profile.d.ts +14 -0
- package/dist/packages/runtime/agents/lib/execution-profile.js +23 -0
- package/dist/packages/runtime/agents/lib/execution.js +14 -8
- package/dist/packages/runtime/agents/lib/executors.d.ts +1 -0
- package/dist/packages/runtime/agents/lib/executors.js +11 -2
- package/dist/packages/runtime/agents/lib/logs.d.ts +10 -0
- package/dist/packages/runtime/agents/lib/logs.js +32 -8
- package/dist/packages/runtime/agents/lib/preflight.js +4 -1
- package/dist/packages/runtime/agents/lib/render.d.ts +18 -0
- package/dist/packages/runtime/agents/lib/render.js +44 -18
- package/dist/packages/runtime/agents/lib/shell-templates.js +105 -63
- package/dist/packages/runtime/agents/lib/shells.d.ts +29 -0
- package/dist/packages/runtime/agents/lib/shells.js +158 -32
- package/dist/packages/runtime/agents/lib/source-context-scan.d.ts +10 -0
- package/dist/packages/runtime/agents/lib/source-context-scan.js +388 -0
- package/dist/packages/runtime/agents/lib/status.js +1 -14
- package/dist/packages/runtime/agents/lib/string-utils.d.ts +16 -0
- package/dist/packages/runtime/agents/lib/string-utils.js +36 -0
- package/dist/packages/runtime/agents/lib/types.d.ts +1 -0
- package/dist/packages/runtime/agents/providers/codex.js +2 -0
- package/dist/packages/runtime/agents/role-executors.js +2 -1
- package/dist/packages/runtime/auth/session-store.js +11 -3
- package/dist/packages/runtime/benchmark-question-draft.d.ts +3 -0
- package/dist/packages/runtime/benchmark-question-draft.js +57 -28
- package/dist/packages/runtime/build/artifact-status.d.ts +1 -1
- package/dist/packages/runtime/build/artifact-status.js +1 -1
- package/dist/packages/runtime/build/build-evidence.d.ts +2 -1
- package/dist/packages/runtime/build/build-evidence.js +11 -5
- package/dist/packages/runtime/build/build-pipeline.js +89 -5
- package/dist/packages/runtime/build/build-stage-plan.js +3 -1
- package/dist/packages/runtime/build/build-stage-runner.js +169 -32
- package/dist/packages/runtime/build/build-target.d.ts +3 -0
- package/dist/packages/runtime/build/build-target.js +25 -1
- package/dist/packages/runtime/build/check-evaluator.d.ts +1 -1
- package/dist/packages/runtime/build/check-evaluator.js +655 -4
- package/dist/packages/runtime/build/context-graph-paths.d.ts +13 -0
- package/dist/packages/runtime/build/context-graph-paths.js +27 -0
- package/dist/packages/runtime/build/index.d.ts +2 -2
- package/dist/packages/runtime/build/index.js +2 -2
- package/dist/packages/runtime/build/inspect-map.d.ts +10 -0
- package/dist/packages/runtime/build/inspect-map.js +270 -0
- package/dist/packages/runtime/build/lib/schema.d.ts +246 -53
- package/dist/packages/runtime/build/lib/schema.js +173 -15
- package/dist/packages/runtime/build/native-entrypoint.d.ts +2 -0
- package/dist/packages/runtime/build/native-entrypoint.js +286 -0
- package/dist/packages/runtime/build/runtime-contracts.js +9 -3
- package/dist/packages/runtime/build/runtime-log-paths.d.ts +3 -0
- package/dist/packages/runtime/build/runtime-log-paths.js +16 -0
- package/dist/packages/runtime/build/runtime-prompt.js +6 -4
- package/dist/packages/runtime/build/runtime-runs.js +63 -10
- package/dist/packages/runtime/build/runtime-types.d.ts +4 -1
- package/dist/packages/runtime/build/runtime.d.ts +3 -1
- package/dist/packages/runtime/build/runtime.js +3 -1
- package/dist/packages/runtime/build/source-files.js +11 -2
- package/dist/packages/runtime/build/source-inventory.d.ts +1 -0
- package/dist/packages/runtime/build/source-inventory.js +246 -7
- package/dist/packages/runtime/build/source-manifest.d.ts +11 -0
- package/dist/packages/runtime/build/source-manifest.js +30 -2
- package/dist/packages/runtime/build/stage-evidence.js +80 -11
- package/dist/packages/runtime/build/stage-manifest.d.ts +45 -0
- package/dist/packages/runtime/build/stage-manifest.js +1125 -0
- package/dist/packages/runtime/build/stage-reuse.js +12 -0
- package/dist/packages/runtime/build/stage-session.d.ts +81 -0
- package/dist/packages/runtime/build/stage-session.js +308 -0
- package/dist/packages/runtime/build/state-io.js +10 -11
- package/dist/packages/runtime/build/state-view.js +1 -1
- package/dist/packages/runtime/build/state.d.ts +1 -1
- package/dist/packages/runtime/build/state.js +1 -1
- package/dist/packages/runtime/build/summary-coverage-index.d.ts +21 -0
- package/dist/packages/runtime/build/summary-coverage-index.js +189 -0
- package/dist/packages/runtime/build/traces.js +3 -3
- package/dist/packages/runtime/build/validate-context-graph.d.ts +1 -1
- package/dist/packages/runtime/build/validate-context-graph.js +5 -5
- package/dist/packages/runtime/build/validate.d.ts +1 -1
- package/dist/packages/runtime/build/validate.js +1 -1
- package/dist/packages/runtime/client.d.ts +3 -3
- package/dist/packages/runtime/client.js +8 -13
- package/dist/packages/runtime/context-checks.js +13 -0
- package/dist/packages/runtime/context-graph-scaffold.js +2 -1
- package/dist/packages/runtime/context-graph-semantic-graph.d.ts +9 -0
- package/dist/packages/runtime/context-graph-semantic-graph.js +416 -0
- package/dist/packages/runtime/execution/lib/schema.d.ts +34 -31
- package/dist/packages/runtime/index.d.ts +2 -2
- package/dist/packages/runtime/index.js +1 -1
- package/dist/packages/runtime/native-run-handlers.d.ts +38 -0
- package/dist/packages/runtime/native-run-handlers.js +52 -33
- package/dist/packages/runtime/plan-artifact-contract.js +1 -1
- package/dist/packages/runtime/project-source-state.d.ts +4 -4
- package/dist/packages/runtime/project-source-state.js +5 -2
- package/dist/packages/runtime/project-store.d.ts +5 -0
- package/dist/packages/runtime/project-store.js +30 -3
- package/dist/packages/runtime/requested-artifacts.js +1 -1
- package/dist/packages/runtime/run-observability.js +9 -4
- package/dist/packages/runtime/runtime-action-proposals.js +3 -3
- package/dist/packages/runtime/runtime-build-plans.js +47 -3
- package/dist/packages/runtime/runtime-build-runs.js +9 -16
- package/dist/packages/runtime/runtime-caches.d.ts +26 -0
- package/dist/packages/runtime/runtime-caches.js +47 -0
- package/dist/packages/runtime/runtime-jobs.js +6 -6
- package/dist/packages/runtime/runtime-project-mutations.js +1 -0
- package/dist/packages/runtime/runtime-project-reads.d.ts +4 -1
- package/dist/packages/runtime/runtime-project-reads.js +229 -36
- package/dist/packages/runtime/runtime-proposal-helpers.js +6 -6
- package/dist/packages/runtime/runtime-resource-builders.d.ts +4 -2
- package/dist/packages/runtime/runtime-resource-builders.js +16 -14
- package/dist/packages/runtime/runtime-status.d.ts +14 -0
- package/dist/packages/runtime/runtime-status.js +15 -0
- package/dist/packages/runtime/runtime-verify-runs.js +6 -5
- package/dist/packages/runtime/runtime.d.ts +439 -22
- package/dist/packages/runtime/runtime.js +16 -2
- package/dist/packages/runtime/schemas/actions.d.ts +24 -0
- package/dist/packages/runtime/schemas/agents.d.ts +28 -0
- package/dist/packages/runtime/schemas/agents.js +33 -0
- package/dist/packages/runtime/schemas/build-plans.d.ts +181 -8
- package/dist/packages/runtime/schemas/build-plans.js +36 -2
- package/dist/packages/runtime/schemas/context-graphs.d.ts +1522 -0
- package/dist/packages/runtime/schemas/context-graphs.js +110 -0
- package/dist/packages/runtime/schemas/files.d.ts +7 -347
- package/dist/packages/runtime/schemas/files.js +1 -24
- package/dist/packages/runtime/schemas/index.d.ts +1 -0
- package/dist/packages/runtime/schemas/index.js +1 -0
- package/dist/packages/runtime/schemas/jobs.js +4 -0
- package/dist/packages/runtime/schemas/projects.d.ts +48 -21
- package/dist/packages/runtime/schemas/projects.js +34 -10
- package/dist/packages/runtime/schemas/runs.d.ts +1009 -240
- package/dist/packages/runtime/schemas/runs.js +17 -0
- package/dist/packages/runtime/service/openapi.js +1 -0
- package/dist/packages/runtime/service/operations.d.ts +1666 -145
- package/dist/packages/runtime/service/operations.js +147 -17
- package/dist/packages/runtime/service/routes.d.ts +11 -3
- package/dist/packages/runtime/service/routes.js +11 -3
- package/dist/packages/runtime/service/server-app-boot.js +2 -2
- package/dist/packages/runtime/service/server-helpers.d.ts +11 -0
- package/dist/packages/runtime/service/server-helpers.js +19 -0
- package/dist/packages/runtime/service/server-routes-action-proposals.js +4 -2
- package/dist/packages/runtime/service/server-routes-agents.js +19 -85
- package/dist/packages/runtime/service/server-routes-build-plans.js +14 -11
- package/dist/packages/runtime/service/server-routes-project-context.js +102 -7
- package/dist/packages/runtime/service/server-routes-project-jobs.js +19 -12
- package/dist/packages/runtime/service/server-routes-project-runs.js +5 -2
- package/dist/packages/runtime/service/server-routes-projects.js +6 -2
- package/dist/packages/runtime/service/server-routes-runs.js +11 -4
- package/dist/packages/runtime/verify/lib/schema.js +12 -0
- package/dist/packages/runtime/verify/test-file-guard.d.ts +2 -0
- package/dist/packages/runtime/verify/test-file-guard.js +29 -0
- package/dist/packages/runtime/verify/verify-execution.d.ts +7 -0
- package/dist/packages/runtime/verify/verify-execution.js +109 -35
- package/dist/packages/runtime/verify/verify-paths.d.ts +1 -0
- package/dist/packages/runtime/verify/verify-paths.js +4 -0
- package/dist/packages/runtime/verify/verify-specs.js +49 -39
- package/dist/packages/runtime/wire-schemas.d.ts +1 -1
- package/dist/packages/runtime/wire-schemas.js +1 -1
- package/package.json +2 -8
- package/public-repo/CONTRIBUTING.md +10 -3
- package/public-repo/README.md +122 -226
- package/public-repo/build-plans/interf-default/README.md +15 -12
- package/public-repo/build-plans/interf-default/build/stages/entrypoint/SKILL.md +74 -0
- package/public-repo/build-plans/interf-default/build/stages/knowledge/SKILL.md +95 -0
- package/public-repo/build-plans/interf-default/build/stages/summarize/SKILL.md +38 -5
- package/public-repo/build-plans/interf-default/build-plan.json +27 -23
- package/public-repo/build-plans/interf-default/build-plan.schema.json +24 -20
- package/public-repo/build-plans/interf-default/use/query/SKILL.md +8 -7
- package/public-repo/openapi/local-service.openapi.json +11637 -4213
- package/public-repo/skills/interf/SKILL.md +174 -134
- package/dist/packages/runtime/build/runtime-paths.d.ts +0 -8
- package/dist/packages/runtime/build/runtime-paths.js +0 -26
- package/dist/packages/runtime/build/state-paths.d.ts +0 -7
- package/dist/packages/runtime/build/state-paths.js +0 -22
- package/public-repo/build-plans/interf-default/build/stages/shape/SKILL.md +0 -34
- package/public-repo/build-plans/interf-default/build/stages/structure/SKILL.md +0 -28
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import { listFilesRecursive } from "../../contracts/utils/filesystem.js";
|
|
4
4
|
import { parseJsonFrontmatter } from "../../contracts/utils/parse.js";
|
|
5
|
+
import { CANONICAL_LAYER_DIRS, HOME_SPINE_FILE, graphRelativePathPattern, } from "../../contracts/lib/context-graph-layer.js";
|
|
5
6
|
import { CheckKindSchema, SourceManifestSchema, } from "../../contracts/lib/schema.js";
|
|
6
7
|
import { countBrokenWikilinks, isOutputMarkdownFile, validateSynthFiles, } from "./validate.js";
|
|
7
8
|
/**
|
|
@@ -143,6 +144,464 @@ function sourceSummaryCoverage(absolutePath, manifest, options) {
|
|
|
143
144
|
extraSummaryFolders: [...evidenceDirs].filter((dir) => !matchedDirs.has(dir)).sort((left, right) => left.localeCompare(right)),
|
|
144
145
|
};
|
|
145
146
|
}
|
|
147
|
+
const WIKILINK_TARGET_PATTERN = /\[\[([^[\]\n]+)\]\]/g;
|
|
148
|
+
const MARKDOWN_LINK_TARGET_PATTERN = /!?\[[^\]\n]*\]\(([^)\n]+)\)/g;
|
|
149
|
+
function normalizeGraphPath(value) {
|
|
150
|
+
return value.replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/\.md$/i, "").replace(/\/+$/g, "");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Like normalizeGraphPath but preserves a trailing `.md` — summary folders are
|
|
154
|
+
* named after the source file and legitimately end in `.md` (e.g.
|
|
155
|
+
* `summaries/meeting-notes.md/`). Stripping it would split the folder identity.
|
|
156
|
+
*/
|
|
157
|
+
function normalizeFolderPath(value) {
|
|
158
|
+
return value.replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/\/+$/g, "");
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Wikilink targets referenced by a single note, normalized to graph-relative
|
|
162
|
+
* basenames (no `.md`, no `#anchor`, no `|alias`). Source-agnostic: reads only
|
|
163
|
+
* the `[[...]]` syntax, never a task taxonomy.
|
|
164
|
+
*/
|
|
165
|
+
function noteWikilinkTargets(content) {
|
|
166
|
+
const targets = [];
|
|
167
|
+
for (const match of content.matchAll(WIKILINK_TARGET_PATTERN)) {
|
|
168
|
+
const raw = match[1]?.split("|")[0]?.split("#")[0]?.trim();
|
|
169
|
+
if (raw)
|
|
170
|
+
targets.push(normalizeGraphPath(raw));
|
|
171
|
+
}
|
|
172
|
+
return targets;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Every link token a note references, across the three link forms the
|
|
176
|
+
* StageManifest's `parseLinks` reads: `[[wikilinks]]`, `[markdown](relative.md)`
|
|
177
|
+
* links (http(s) excluded), and bare graph-relative path mentions in body text
|
|
178
|
+
* (`knowledge/foo`, via the shared `graphRelativePathPattern` so the scanner and
|
|
179
|
+
* the layer model can never list different folders). Each token is normalized to
|
|
180
|
+
* a graph-relative path with no `.md`, anchor, or alias. Reusing the same link
|
|
181
|
+
* surface the manifest uses is what keeps the Check and the manifest rollup in
|
|
182
|
+
* lockstep on which notes are web-connected.
|
|
183
|
+
*/
|
|
184
|
+
function noteLinkTargets(content) {
|
|
185
|
+
const targets = new Set();
|
|
186
|
+
for (const target of noteWikilinkTargets(content))
|
|
187
|
+
targets.add(target);
|
|
188
|
+
for (const match of content.matchAll(MARKDOWN_LINK_TARGET_PATTERN)) {
|
|
189
|
+
const raw = match[1]?.split("#")[0]?.trim();
|
|
190
|
+
if (!raw || /^https?:\/\//i.test(raw))
|
|
191
|
+
continue;
|
|
192
|
+
targets.add(normalizeGraphPath(raw));
|
|
193
|
+
}
|
|
194
|
+
for (const match of content.matchAll(graphRelativePathPattern())) {
|
|
195
|
+
const raw = match[1]?.split("#")[0]?.trim().replace(/[),.;:]+$/g, "");
|
|
196
|
+
if (raw)
|
|
197
|
+
targets.add(normalizeGraphPath(raw));
|
|
198
|
+
}
|
|
199
|
+
return [...targets].filter((target) => target.length > 0);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Resolve a single link token to the knowledge note it names, or report that it
|
|
203
|
+
* is ambiguous. Mirrors the basename-aliasing discipline `existingSummaryFolderSet`
|
|
204
|
+
* already uses in this file: a full graph-path always resolves (paths are unique
|
|
205
|
+
* within the subtree); a token ending `/<basename>` resolves to the unique note
|
|
206
|
+
* with that path suffix; a BARE basename resolves ONLY when exactly one note
|
|
207
|
+
* carries it. A bare basename two or more notes share (e.g. `[[claim]]` for both
|
|
208
|
+
* `topics/claim` and `entities/claim`) is `ambiguous` — it must NOT credit a web
|
|
209
|
+
* edge to one arbitrarily, which would silently connect the linker and hide the
|
|
210
|
+
* other namesake's island. Returns the matched note, `ambiguous`, or `null`.
|
|
211
|
+
*/
|
|
212
|
+
function resolveLinkToNote(token, byGraphPath, byBasename, ambiguousBasenames) {
|
|
213
|
+
const clean = normalizeGraphPath(token);
|
|
214
|
+
if (clean.length === 0)
|
|
215
|
+
return null;
|
|
216
|
+
const exact = byGraphPath.get(clean);
|
|
217
|
+
if (exact)
|
|
218
|
+
return exact;
|
|
219
|
+
if (clean.includes("/")) {
|
|
220
|
+
// A path-shaped token: credit a note whose full path is the token's trailing
|
|
221
|
+
// segments. Unique by construction (graph paths are unique), so no ambiguity.
|
|
222
|
+
const suffix = basename(clean);
|
|
223
|
+
for (const note of byGraphPath.values()) {
|
|
224
|
+
if (clean.endsWith(`/${note.base}`) && note.base === suffix && clean.endsWith(note.graphPath)) {
|
|
225
|
+
return note;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
// A bare basename: resolves only when unambiguous; a shared basename is a gap.
|
|
231
|
+
if (ambiguousBasenames.has(clean))
|
|
232
|
+
return "ambiguous";
|
|
233
|
+
return byBasename.get(clean) ?? null;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Resolve a note-relative link token (`./x`, `../x`) against the linking note's
|
|
237
|
+
* directory into an absolute graph path, so a relative wikilink credits a web
|
|
238
|
+
* edge the same way the wikilink validator resolves it. Without this, a note
|
|
239
|
+
* that links `[[../launch]]` is falsely scored a disconnected island. Non-relative
|
|
240
|
+
* tokens (full graph paths, bare basenames) are returned untouched for the global
|
|
241
|
+
* lookup.
|
|
242
|
+
*/
|
|
243
|
+
function resolveRelativeGraphPath(fromGraphPath, token) {
|
|
244
|
+
const lastSlash = fromGraphPath.lastIndexOf("/");
|
|
245
|
+
const segments = lastSlash >= 0 ? fromGraphPath.slice(0, lastSlash).split("/") : [];
|
|
246
|
+
for (const part of token.split("/")) {
|
|
247
|
+
if (part === "" || part === ".")
|
|
248
|
+
continue;
|
|
249
|
+
if (part === "..") {
|
|
250
|
+
segments.pop();
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
segments.push(part);
|
|
254
|
+
}
|
|
255
|
+
return normalizeGraphPath(segments.join("/"));
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Markdown notes that form the CONTENT of a Context Graph — the canonical content
|
|
259
|
+
* layers (`summaries/`, `knowledge/`, `artifacts/`) plus the `home.md` spine —
|
|
260
|
+
* resolved to absolute file paths. Deliberately EXCLUDES the runtime scaffolding
|
|
261
|
+
* that also lives under the graph root (`.interf/`, `.claude/`, `.agents/`,
|
|
262
|
+
* skill `SKILL.md` docs, `CLAUDE.md`, `AGENTS.md`, view specs): those are not
|
|
263
|
+
* graph notes and must never be scored for web connectivity — they are always
|
|
264
|
+
* "islands" and several share the basename `SKILL`, which would inject false
|
|
265
|
+
* ambiguity. This mirrors the dot-entry skipping the semantic-graph builder
|
|
266
|
+
* already does, and keys off the central `CANONICAL_LAYER_DIRS` / `HOME_SPINE_FILE`
|
|
267
|
+
* so the connectivity floor and the layer model never list different folders.
|
|
268
|
+
*/
|
|
269
|
+
function collectGraphContentNotes(graphRoot) {
|
|
270
|
+
const files = [];
|
|
271
|
+
for (const layer of CANONICAL_LAYER_DIRS) {
|
|
272
|
+
files.push(...listMarkdownFiles(join(graphRoot, layer)));
|
|
273
|
+
}
|
|
274
|
+
const home = join(graphRoot, HOME_SPINE_FILE);
|
|
275
|
+
if (existsSync(home) && isOutputMarkdownFile(home))
|
|
276
|
+
files.push(home);
|
|
277
|
+
return files;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Note-web connectivity over a pre-collected note set — the filesystem-side
|
|
281
|
+
* mirror of the StageManifest's `knowledgeWebConnectivity`. Each file in `noteFiles`
|
|
282
|
+
* IS a note in the web (the caller chooses the scope and the file set: the
|
|
283
|
+
* knowledge layer for `knowledge_web_connectivity`, the canonical CONTENT layers
|
|
284
|
+
* for `graph_notes_connected`), so no layer re-derivation is needed here. A note
|
|
285
|
+
* is web-connected when it links a DIFFERENT note in the same set OR is linked
|
|
286
|
+
* by one (UNDIRECTED, degree ≥ 1); degree 0 is a disconnected island.
|
|
287
|
+
* Connectedness, not a count. Vacuous pass: ≤ 1 note cannot form a web, so it is
|
|
288
|
+
* reported connected with no islands. Edges and matching reuse the same link
|
|
289
|
+
* surface and basename-aliasing rule the manifest and the backlink check use, so
|
|
290
|
+
* the gates agree on islands.
|
|
291
|
+
*/
|
|
292
|
+
function analyzeNoteWeb(noteFiles, context) {
|
|
293
|
+
const root = resolve(context.rootPath);
|
|
294
|
+
const notes = noteFiles.map((file) => {
|
|
295
|
+
const graphPath = normalizeGraphPath(relative(root, file).replaceAll("\\", "/"));
|
|
296
|
+
return { file, graphPath, base: basename(graphPath) };
|
|
297
|
+
});
|
|
298
|
+
if (notes.length <= 1) {
|
|
299
|
+
return { notes: notes.length, connected: notes.length, islands: [], ambiguousLinkNotes: [] };
|
|
300
|
+
}
|
|
301
|
+
// Full path always keys a note; a basename keys a note only when unique. A
|
|
302
|
+
// basename two or more notes share is ambiguous and never resolves a bare link.
|
|
303
|
+
const byGraphPath = new Map();
|
|
304
|
+
const basenameCounts = new Map();
|
|
305
|
+
for (const note of notes) {
|
|
306
|
+
byGraphPath.set(note.graphPath, note);
|
|
307
|
+
basenameCounts.set(note.base, (basenameCounts.get(note.base) ?? 0) + 1);
|
|
308
|
+
}
|
|
309
|
+
const byBasename = new Map();
|
|
310
|
+
const ambiguousBasenames = new Set();
|
|
311
|
+
for (const note of notes) {
|
|
312
|
+
if ((basenameCounts.get(note.base) ?? 0) > 1) {
|
|
313
|
+
ambiguousBasenames.add(note.base);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
byBasename.set(note.base, note);
|
|
317
|
+
}
|
|
318
|
+
const hasOutbound = new Set();
|
|
319
|
+
const hasInbound = new Set();
|
|
320
|
+
const ambiguousLinkNotes = new Set();
|
|
321
|
+
for (const from of notes) {
|
|
322
|
+
for (const token of noteLinkTargets(readFileSync(from.file, "utf8"))) {
|
|
323
|
+
const candidate = token.startsWith("../") || token.startsWith("./")
|
|
324
|
+
? resolveRelativeGraphPath(from.graphPath, token)
|
|
325
|
+
: token;
|
|
326
|
+
const resolved = resolveLinkToNote(candidate, byGraphPath, byBasename, ambiguousBasenames);
|
|
327
|
+
if (resolved === "ambiguous") {
|
|
328
|
+
ambiguousLinkNotes.add(from.graphPath);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (!resolved || resolved.graphPath === from.graphPath)
|
|
332
|
+
continue;
|
|
333
|
+
hasOutbound.add(from.graphPath);
|
|
334
|
+
hasInbound.add(resolved.graphPath);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const islands = notes
|
|
338
|
+
.filter((note) => !hasOutbound.has(note.graphPath) && !hasInbound.has(note.graphPath))
|
|
339
|
+
.map((note) => note.graphPath)
|
|
340
|
+
.sort((left, right) => left.localeCompare(right));
|
|
341
|
+
return {
|
|
342
|
+
notes: notes.length,
|
|
343
|
+
connected: notes.length - islands.length,
|
|
344
|
+
islands,
|
|
345
|
+
ambiguousLinkNotes: [...ambiguousLinkNotes].sort((left, right) => left.localeCompare(right)),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Source refs a note declares, drawn from frontmatter source keys and from any
|
|
350
|
+
* literal source path mentioned in the body. Mirrors the StageManifest reader so
|
|
351
|
+
* the check and the manifest agree on what a note "cites".
|
|
352
|
+
*/
|
|
353
|
+
function noteSourceRefs(content, frontmatter, knownSourcePaths) {
|
|
354
|
+
const refs = new Set();
|
|
355
|
+
for (const key of ["source_refs", "source_ref", "source_path", "source"]) {
|
|
356
|
+
const value = frontmatter[key];
|
|
357
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
358
|
+
refs.add(value.trim());
|
|
359
|
+
if (Array.isArray(value)) {
|
|
360
|
+
for (const entry of value) {
|
|
361
|
+
if (typeof entry === "string" && entry.trim().length > 0)
|
|
362
|
+
refs.add(entry.trim());
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const sourcePath of knownSourcePaths) {
|
|
367
|
+
if (content.includes(sourcePath))
|
|
368
|
+
refs.add(sourcePath);
|
|
369
|
+
}
|
|
370
|
+
return [...refs];
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Resolve which summary folders actually exist on disk (folders under the
|
|
374
|
+
* summaries directory that hold a summary or manifest file). Returns a lookup
|
|
375
|
+
* from any reasonable reference form — the folder's graph-relative path and its
|
|
376
|
+
* basename — to the canonical folder path, plus the set of basenames that more
|
|
377
|
+
* than one folder carries. Reuses the same summary/manifest evidence convention
|
|
378
|
+
* the summary-coverage check uses.
|
|
379
|
+
*
|
|
380
|
+
* A basename alias is registered ONLY when that basename is unambiguous across
|
|
381
|
+
* summary folders. When two folders share a basename (e.g. `dept/report.pdf`
|
|
382
|
+
* and `legal/report.pdf` both basename `report.pdf`), aliasing one of them
|
|
383
|
+
* would silently resolve a basename-only ref to the wrong folder and HIDE a
|
|
384
|
+
* real orphan. Ambiguous basenames are left out of the `lookup` so a
|
|
385
|
+
* basename-only ref to them never resolves to one arbitrary folder; the
|
|
386
|
+
* `ambiguousBasenames` set lets the caller treat such a ref as an unresolved
|
|
387
|
+
* gap to surface rather than a silent pass. Full-path refs always resolve
|
|
388
|
+
* regardless of basename collisions.
|
|
389
|
+
*/
|
|
390
|
+
function existingSummaryFolderSet(summariesAbsolutePath, options) {
|
|
391
|
+
const lookup = new Map();
|
|
392
|
+
const evidenceDirs = summaryDirectoriesWithEvidence(summariesAbsolutePath, options);
|
|
393
|
+
const folders = [];
|
|
394
|
+
const basenameCounts = new Map();
|
|
395
|
+
for (const dir of evidenceDirs) {
|
|
396
|
+
const folder = normalizeFolderPath(dir);
|
|
397
|
+
if (folder.length === 0)
|
|
398
|
+
continue;
|
|
399
|
+
folders.push(folder);
|
|
400
|
+
// Full-path key always wins; it is unique per folder.
|
|
401
|
+
lookup.set(folder, folder);
|
|
402
|
+
const base = basename(folder);
|
|
403
|
+
if (base)
|
|
404
|
+
basenameCounts.set(base, (basenameCounts.get(base) ?? 0) + 1);
|
|
405
|
+
}
|
|
406
|
+
const ambiguousBasenames = new Set();
|
|
407
|
+
// Second pass: alias a basename to its folder only when exactly one folder
|
|
408
|
+
// carries that basename. Skip any basename that already collides with a
|
|
409
|
+
// full-path key (a folder literally named like another folder's basename) —
|
|
410
|
+
// the path key must not be shadowed.
|
|
411
|
+
for (const folder of folders) {
|
|
412
|
+
const base = basename(folder);
|
|
413
|
+
if (!base || base === folder)
|
|
414
|
+
continue;
|
|
415
|
+
if ((basenameCounts.get(base) ?? 0) > 1) {
|
|
416
|
+
ambiguousBasenames.add(base);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (lookup.has(base))
|
|
420
|
+
continue;
|
|
421
|
+
lookup.set(base, folder);
|
|
422
|
+
}
|
|
423
|
+
return { lookup, ambiguousBasenames };
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Whether one normalized graph path contains another at a path-segment (or
|
|
427
|
+
* trailing-delimiter) boundary, not mid-segment. `decks/q3.pptx` contains
|
|
428
|
+
* `decks/q3.pptx/pages/3` and `decks/q3.pptx#page=25`, but `decks/q3.pptx` does
|
|
429
|
+
* NOT contain `decks/q3.pptx-archive` — the suffix must begin at a `/` or `#`
|
|
430
|
+
* boundary. Source-agnostic: pure string-shape, no task taxonomy.
|
|
431
|
+
*/
|
|
432
|
+
function containsAtBoundary(container, inner) {
|
|
433
|
+
if (inner.length === 0 || container.length < inner.length)
|
|
434
|
+
return false;
|
|
435
|
+
const index = container.indexOf(inner);
|
|
436
|
+
if (index < 0)
|
|
437
|
+
return false;
|
|
438
|
+
// Inner must start at a segment boundary (path start or right after a "/").
|
|
439
|
+
if (index > 0 && container[index - 1] !== "/")
|
|
440
|
+
return false;
|
|
441
|
+
// Inner must end at a segment boundary (path end, or before a "/" / "#"
|
|
442
|
+
// anchor). Anything else (e.g. "q3.pptx" inside "q3.pptx-archive") is a
|
|
443
|
+
// mid-segment false match.
|
|
444
|
+
const after = container[index + inner.length];
|
|
445
|
+
return after === undefined || after === "/" || after === "#";
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Map an arbitrary source ref to the summary-folder names that could hold its
|
|
449
|
+
* summary. Generic across any Source: a ref like `decks/q3.pptx` matches the
|
|
450
|
+
* `decks/q3.pptx` folder, the basename `q3.pptx` (when unambiguous), or the
|
|
451
|
+
* manifest file id. Never hardcodes a task or filename.
|
|
452
|
+
*/
|
|
453
|
+
function summaryFolderCandidatesForRef(ref, manifest, basenameCounts) {
|
|
454
|
+
const normalizedRef = ref.replaceAll("\\", "/");
|
|
455
|
+
const normalized = normalizeFolderPath(ref).replace(/^summaries\//, "");
|
|
456
|
+
const candidates = new Set();
|
|
457
|
+
candidates.add(normalized);
|
|
458
|
+
const base = basename(normalized);
|
|
459
|
+
if (base)
|
|
460
|
+
candidates.add(base);
|
|
461
|
+
// Resolve through the Source Manifest so a page-level or partial ref still
|
|
462
|
+
// points at the file-level summary folder. Match only at path-segment
|
|
463
|
+
// boundaries: a bare substring test over-matches (`q3` would hit
|
|
464
|
+
// `q3-archive`), crediting the wrong summary and hiding a real orphan.
|
|
465
|
+
for (const file of manifest?.files ?? []) {
|
|
466
|
+
const filePath = file.path.replaceAll("\\", "/");
|
|
467
|
+
if (containsAtBoundary(normalizedRef, filePath) ||
|
|
468
|
+
containsAtBoundary(filePath, normalized) ||
|
|
469
|
+
containsAtBoundary(normalized, filePath)) {
|
|
470
|
+
candidates.add(filePath);
|
|
471
|
+
if (basenameCounts.get(basename(filePath)) === 1)
|
|
472
|
+
candidates.add(basename(filePath));
|
|
473
|
+
candidates.add(file.id);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return [...candidates].filter((candidate) => candidate.length > 0);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Orphaned-summary analysis for a knowledge-style layer: a summary folder that is
|
|
480
|
+
* cited by some note's source_refs but is wikilinked by no note in the layer.
|
|
481
|
+
* Fully generic — derives expected backlinks from the notes' own refs and the
|
|
482
|
+
* Source Manifest, with no project-specific or task-specific input.
|
|
483
|
+
*/
|
|
484
|
+
function analyzeSummaryBacklinks(layerDir, context, options) {
|
|
485
|
+
const root = resolve(context.rootPath);
|
|
486
|
+
const manifest = loadSourceManifestForCheck(context).manifest;
|
|
487
|
+
const knownSourcePaths = manifest?.files.map((file) => file.path.replaceAll("\\", "/")) ?? [];
|
|
488
|
+
const basenameCounts = new Map();
|
|
489
|
+
for (const path of knownSourcePaths) {
|
|
490
|
+
const base = basename(path);
|
|
491
|
+
basenameCounts.set(base, (basenameCounts.get(base) ?? 0) + 1);
|
|
492
|
+
}
|
|
493
|
+
// Summary folders that actually exist on disk. You can only orphan a summary
|
|
494
|
+
// that exists — a note citing a source with no summary folder is not a
|
|
495
|
+
// backlink violation (there is nothing to link to). The lookup keys a folder
|
|
496
|
+
// by its full path and, only when unambiguous, its basename; ambiguousBasenames
|
|
497
|
+
// names the basenames carried by more than one folder.
|
|
498
|
+
const { lookup: existingSummaryFolders, ambiguousBasenames } = existingSummaryFolderSet(join(root, options.summariesDir), { summaryFile: options.summaryFile, manifestFile: options.manifestFile });
|
|
499
|
+
// Resolve a ref to a canonical summary folder. Three outcomes:
|
|
500
|
+
// "resolved" — a candidate matched an existing folder.
|
|
501
|
+
// "ambiguous" — nothing matched, but a basename candidate collides with two
|
|
502
|
+
// or more existing folders, so we refuse to pick one. This is a
|
|
503
|
+
// gap to surface, not a clean miss.
|
|
504
|
+
// "absent" — nothing matched and no summary folder exists for the ref.
|
|
505
|
+
const resolveCitedFolder = (ref) => {
|
|
506
|
+
const candidates = summaryFolderCandidatesForRef(ref, manifest, basenameCounts);
|
|
507
|
+
for (const candidate of candidates) {
|
|
508
|
+
const folder = existingSummaryFolders.get(candidate);
|
|
509
|
+
if (folder)
|
|
510
|
+
return { kind: "resolved", folder };
|
|
511
|
+
}
|
|
512
|
+
if (candidates.some((candidate) => ambiguousBasenames.has(candidate))) {
|
|
513
|
+
return { kind: "ambiguous" };
|
|
514
|
+
}
|
|
515
|
+
return { kind: "absent" };
|
|
516
|
+
};
|
|
517
|
+
// Set of every summary-folder backlink wikilink target present anywhere in the
|
|
518
|
+
// layer, normalized to the folder name (drop the trailing summary/manifest leaf).
|
|
519
|
+
const linkedSummaries = new Set();
|
|
520
|
+
const summaryLeaf = normalizeGraphPath(options.summaryFile);
|
|
521
|
+
const manifestLeaf = normalizeGraphPath(options.manifestFile);
|
|
522
|
+
const citedSummaryToNotes = new Map();
|
|
523
|
+
const notesWithUnlinkedCitations = new Set();
|
|
524
|
+
const notesWithAmbiguousCitations = new Set();
|
|
525
|
+
let notesScanned = 0;
|
|
526
|
+
const notes = listMarkdownFiles(layerDir);
|
|
527
|
+
// First pass: every existing summary folder linked anywhere in the layer,
|
|
528
|
+
// resolved to its canonical folder name so basename and path links agree. A
|
|
529
|
+
// wikilink is a concrete graph path, so it credits a backlink ONLY when the
|
|
530
|
+
// target resolves to a real summary folder. `existingSummaryFolders` already
|
|
531
|
+
// keys both each folder's full path and its basename (the latter only when
|
|
532
|
+
// unambiguous), so a legitimate bare-basename link still resolves. We do NOT
|
|
533
|
+
// fall back to a separate basename(folderRef) lookup: that would let a broken
|
|
534
|
+
// wikilink — one whose folder path does not exist (e.g.
|
|
535
|
+
// `[[summaries/typo/q3.pptx/summary]]`) — launder through its basename and
|
|
536
|
+
// wrongly credit an unrelated namesake folder, hiding a real orphan.
|
|
537
|
+
for (const note of notes) {
|
|
538
|
+
const content = readFileSync(note, "utf8");
|
|
539
|
+
for (const target of noteWikilinkTargets(content)) {
|
|
540
|
+
const withinSummaries = target.startsWith(`${options.summariesDir}/`)
|
|
541
|
+
? target.slice(options.summariesDir.length + 1)
|
|
542
|
+
: null;
|
|
543
|
+
if (withinSummaries === null)
|
|
544
|
+
continue;
|
|
545
|
+
const segments = withinSummaries.split("/");
|
|
546
|
+
const leaf = segments[segments.length - 1] ?? "";
|
|
547
|
+
const folderRef = leaf === summaryLeaf || leaf === manifestLeaf
|
|
548
|
+
? segments.slice(0, -1).join("/")
|
|
549
|
+
: withinSummaries;
|
|
550
|
+
const folder = existingSummaryFolders.get(folderRef);
|
|
551
|
+
if (folder)
|
|
552
|
+
linkedSummaries.add(folder);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// Second pass: every existing summary a note cites, and whether it is linked.
|
|
556
|
+
for (const note of notes) {
|
|
557
|
+
notesScanned += 1;
|
|
558
|
+
const content = readFileSync(note, "utf8");
|
|
559
|
+
const parsed = parseJsonFrontmatter(content);
|
|
560
|
+
const frontmatter = parsed?.frontmatter ?? {};
|
|
561
|
+
const refs = noteSourceRefs(content, frontmatter, knownSourcePaths);
|
|
562
|
+
if (refs.length === 0)
|
|
563
|
+
continue;
|
|
564
|
+
const noteRel = relative(root, note).replaceAll("\\", "/");
|
|
565
|
+
let noteHasUnlinked = false;
|
|
566
|
+
let noteHasAmbiguous = false;
|
|
567
|
+
for (const ref of refs) {
|
|
568
|
+
const resolution = resolveCitedFolder(ref);
|
|
569
|
+
// An ambiguous basename-only citation cannot be proven backlinked; treat it
|
|
570
|
+
// as a surfaced gap rather than skipping it (which would hide the orphan).
|
|
571
|
+
if (resolution.kind === "ambiguous") {
|
|
572
|
+
noteHasAmbiguous = true;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// Only an existing summary folder can be orphaned; skip refs that point at
|
|
576
|
+
// a source with no summary in this graph.
|
|
577
|
+
if (resolution.kind === "absent")
|
|
578
|
+
continue;
|
|
579
|
+
const folder = resolution.folder;
|
|
580
|
+
const linked = linkedSummaries.has(folder);
|
|
581
|
+
const list = citedSummaryToNotes.get(folder) ?? [];
|
|
582
|
+
list.push(noteRel);
|
|
583
|
+
citedSummaryToNotes.set(folder, list);
|
|
584
|
+
if (!linked)
|
|
585
|
+
noteHasUnlinked = true;
|
|
586
|
+
}
|
|
587
|
+
if (noteHasUnlinked)
|
|
588
|
+
notesWithUnlinkedCitations.add(noteRel);
|
|
589
|
+
if (noteHasAmbiguous)
|
|
590
|
+
notesWithAmbiguousCitations.add(noteRel);
|
|
591
|
+
}
|
|
592
|
+
const citedSummaries = [...citedSummaryToNotes.keys()];
|
|
593
|
+
const orphanedSummaries = citedSummaries
|
|
594
|
+
.filter((summary) => !linkedSummaries.has(summary))
|
|
595
|
+
.sort((left, right) => left.localeCompare(right));
|
|
596
|
+
return {
|
|
597
|
+
citedSummaries,
|
|
598
|
+
linkedSummaries,
|
|
599
|
+
orphanedSummaries,
|
|
600
|
+
ambiguousCitations: [...notesWithAmbiguousCitations].sort((left, right) => left.localeCompare(right)),
|
|
601
|
+
notesWithUnlinkedCitations: [...notesWithUnlinkedCitations].sort((left, right) => left.localeCompare(right)),
|
|
602
|
+
notesScanned,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
146
605
|
function checkPhrases(check) {
|
|
147
606
|
if (Array.isArray(check.params?.phrases)) {
|
|
148
607
|
return check.params.phrases.filter((phrase) => typeof phrase === "string");
|
|
@@ -183,6 +642,44 @@ function collectFrontmatterFailures(files, predicate) {
|
|
|
183
642
|
}
|
|
184
643
|
return { invalid, missing };
|
|
185
644
|
}
|
|
645
|
+
/**
|
|
646
|
+
* Declarative exemption clause for `source_refs_required`: `params.exempt_when`
|
|
647
|
+
* maps a frontmatter key to the values that exempt a note from the requirement
|
|
648
|
+
* (an empty value list means "any non-empty value exempts"). A note is exempt
|
|
649
|
+
* when it declares any matching signal — e.g. a pure index/navigation note that
|
|
650
|
+
* asserts nothing can opt out with `note_role: index` rather than being pushed
|
|
651
|
+
* to fabricate `source_refs`. This is config, not engine taxonomy: the keys and
|
|
652
|
+
* values live in the Build Plan, so no concept is hardcoded in the runtime.
|
|
653
|
+
*/
|
|
654
|
+
function exemptWhenClause(check) {
|
|
655
|
+
const raw = check.params?.exempt_when;
|
|
656
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
657
|
+
return {};
|
|
658
|
+
const out = {};
|
|
659
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
660
|
+
if (typeof key !== "string" || key.trim().length === 0)
|
|
661
|
+
continue;
|
|
662
|
+
out[key] = Array.isArray(value)
|
|
663
|
+
? value.filter((entry) => typeof entry === "string").map((entry) => entry.trim().toLowerCase())
|
|
664
|
+
: [];
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
}
|
|
668
|
+
function isExemptFromSourceRefs(frontmatter, exemptWhen) {
|
|
669
|
+
for (const [key, allowed] of Object.entries(exemptWhen)) {
|
|
670
|
+
const value = frontmatter[key];
|
|
671
|
+
if (!hasNonEmptyFrontmatterValue(value))
|
|
672
|
+
continue;
|
|
673
|
+
if (allowed.length === 0)
|
|
674
|
+
return true;
|
|
675
|
+
if (typeof value === "string" && allowed.includes(value.trim().toLowerCase()))
|
|
676
|
+
return true;
|
|
677
|
+
if (Array.isArray(value) && value.some((entry) => typeof entry === "string" && allowed.includes(entry.trim().toLowerCase()))) {
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
186
683
|
function loadSourceManifestForCheck(context, path = ".interf/runtime/source-manifest.json") {
|
|
187
684
|
const manifestPath = resolve(context.rootPath, path);
|
|
188
685
|
if (!existsSync(manifestPath))
|
|
@@ -379,7 +876,7 @@ const EVALUATORS = {
|
|
|
379
876
|
if (!target) {
|
|
380
877
|
return makeCheckResult(check, false, "No target path provided for frontmatter_required_keys check.");
|
|
381
878
|
}
|
|
382
|
-
const keys =
|
|
879
|
+
const keys = frontmatterKeys(check);
|
|
383
880
|
if (keys.length === 0) {
|
|
384
881
|
return makeCheckResult(check, false, "Build Plan check is missing required frontmatter keys. Use `params.keys: string[]`.");
|
|
385
882
|
}
|
|
@@ -419,16 +916,170 @@ const EVALUATORS = {
|
|
|
419
916
|
}
|
|
420
917
|
const keys = frontmatterKeys(check);
|
|
421
918
|
const sourceRefKeys = keys.length > 0 ? keys : ["source_refs", "source_ref", "source_path"];
|
|
919
|
+
const exemptWhen = exemptWhenClause(check);
|
|
422
920
|
const files = listMarkdownFiles(target);
|
|
423
921
|
if (files.length === 0) {
|
|
424
922
|
return makeCheckResult(check, false, "No markdown files to validate.");
|
|
425
923
|
}
|
|
426
|
-
const { invalid, missing } = collectFrontmatterFailures(files, (frontmatter) =>
|
|
924
|
+
const { invalid, missing } = collectFrontmatterFailures(files, (frontmatter) => isExemptFromSourceRefs(frontmatter, exemptWhen)
|
|
925
|
+
|| sourceRefKeys.some((key) => hasNonEmptyFrontmatterValue(frontmatter[key])));
|
|
427
926
|
if (invalid.length === 0 && missing.length === 0) {
|
|
428
927
|
return makeCheckResult(check, true, `All ${files.length} markdown file(s) have source refs.`);
|
|
429
928
|
}
|
|
430
929
|
return makeCheckResult(check, false, `${invalid.length + missing.length} of ${files.length} markdown file(s) are missing source refs.`, { invalid, missing, sourceRefKeys });
|
|
431
930
|
},
|
|
931
|
+
summary_backlinks_present(check, context) {
|
|
932
|
+
const target = resolveTargetPath(check, context);
|
|
933
|
+
if (!target) {
|
|
934
|
+
return makeCheckResult(check, false, "No target path provided for summary_backlinks_present check.");
|
|
935
|
+
}
|
|
936
|
+
const summariesDir = typeof check.params?.summaries_dir === "string" ? check.params.summaries_dir : "summaries";
|
|
937
|
+
const summaryFile = typeof check.params?.summary_file === "string" ? check.params.summary_file : "summary.md";
|
|
938
|
+
const manifestFile = typeof check.params?.manifest_file === "string" ? check.params.manifest_file : "manifest.md";
|
|
939
|
+
const analysis = analyzeSummaryBacklinks(target, context, { summariesDir, summaryFile, manifestFile });
|
|
940
|
+
// An ambiguous basename-only citation cannot be proven backlinked: it names a
|
|
941
|
+
// basename two or more summary folders share, so we refuse to credit one
|
|
942
|
+
// arbitrarily. Surface it as a gap rather than silently passing — that is the
|
|
943
|
+
// exact orphan the old first-basename-wins alias hid. Fails even when nothing
|
|
944
|
+
// resolved cleanly, because the citation itself is unverifiable.
|
|
945
|
+
if (analysis.ambiguousCitations.length > 0) {
|
|
946
|
+
return makeCheckResult(check, false, `${analysis.ambiguousCitations.length} note(s) cite a summary by an ambiguous basename that two or more summary folders share; cite the full summary-folder path so the backlink can be verified.`, {
|
|
947
|
+
cited_summaries: analysis.citedSummaries.length,
|
|
948
|
+
linked_summaries: analysis.linkedSummaries.size,
|
|
949
|
+
ambiguous_citations: analysis.ambiguousCitations.length,
|
|
950
|
+
notes_with_ambiguous_citations: analysis.ambiguousCitations,
|
|
951
|
+
orphaned_summaries: analysis.orphanedSummaries.length,
|
|
952
|
+
orphaned_summary_folders: analysis.orphanedSummaries,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
if (analysis.citedSummaries.length === 0) {
|
|
956
|
+
return makeCheckResult(check, true, "No notes cite a summary source ref yet; nothing to backlink.", { notesScanned: analysis.notesScanned });
|
|
957
|
+
}
|
|
958
|
+
if (analysis.orphanedSummaries.length === 0) {
|
|
959
|
+
return makeCheckResult(check, true, `All ${analysis.citedSummaries.length} cited summary folder(s) are wikilinked from this layer.`, {
|
|
960
|
+
cited_summaries: analysis.citedSummaries.length,
|
|
961
|
+
linked_summaries: analysis.linkedSummaries.size,
|
|
962
|
+
orphaned_summaries: 0,
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
return makeCheckResult(check, false, `${analysis.orphanedSummaries.length} of ${analysis.citedSummaries.length} cited summary folder(s) are orphaned (cited via source_refs but never wikilinked).`, {
|
|
966
|
+
cited_summaries: analysis.citedSummaries.length,
|
|
967
|
+
linked_summaries: analysis.linkedSummaries.size,
|
|
968
|
+
orphaned_summaries: analysis.orphanedSummaries.length,
|
|
969
|
+
orphaned_summary_folders: analysis.orphanedSummaries,
|
|
970
|
+
notes_with_unlinked_citations: analysis.notesWithUnlinkedCitations,
|
|
971
|
+
});
|
|
972
|
+
},
|
|
973
|
+
knowledge_web_connectivity(check, context) {
|
|
974
|
+
const target = resolveTargetPath(check, context);
|
|
975
|
+
if (!target) {
|
|
976
|
+
return makeCheckResult(check, false, "No target path provided for knowledge_web_connectivity check.");
|
|
977
|
+
}
|
|
978
|
+
// The artifact's target path IS the knowledge layer to scan — derived from the
|
|
979
|
+
// artifact this check is attached to, never hardcoded to `knowledge/` from the
|
|
980
|
+
// root. `params.knowledge_dir` is a graph-root-relative override that may only
|
|
981
|
+
// NARROW the scan to a sub-directory inside the target; an override that escapes
|
|
982
|
+
// the target layer (e.g. a sibling layer) is rejected so the check cannot be
|
|
983
|
+
// pointed at a different, possibly-passing subtree.
|
|
984
|
+
let scanRoot = target;
|
|
985
|
+
if (typeof check.params?.knowledge_dir === "string" && check.params.knowledge_dir.trim().length > 0) {
|
|
986
|
+
const narrowed = resolve(context.rootPath, check.params.knowledge_dir);
|
|
987
|
+
if (narrowed !== target && !narrowed.startsWith(`${target}/`)) {
|
|
988
|
+
return makeCheckResult(check, false, "params.knowledge_dir resolves outside this check's target layer; it may only narrow the scan inside the target.", { knowledge_dir: check.params.knowledge_dir, target_dir: context.targetPath });
|
|
989
|
+
}
|
|
990
|
+
scanRoot = narrowed;
|
|
991
|
+
}
|
|
992
|
+
const web = analyzeNoteWeb(listMarkdownFiles(scanRoot), context);
|
|
993
|
+
// A bare basename two or more notes share cannot be proven to connect either
|
|
994
|
+
// namesake, so it credits no edge — surface it as a "cite the full path" gap
|
|
995
|
+
// rather than silently connecting the linker and hiding an island. Fails even
|
|
996
|
+
// when no island remains, because the link itself is unverifiable.
|
|
997
|
+
if (web.ambiguousLinkNotes.length > 0) {
|
|
998
|
+
return makeCheckResult(check, false, `${web.ambiguousLinkNotes.length} knowledge note(s) link another knowledge note by an ambiguous basename two or more notes share; link the full graph path so the web edge can be verified.`, {
|
|
999
|
+
knowledge_notes: web.notes,
|
|
1000
|
+
connected: web.connected,
|
|
1001
|
+
islands: web.islands.length,
|
|
1002
|
+
island_notes: web.islands,
|
|
1003
|
+
ambiguous_links: web.ambiguousLinkNotes.length,
|
|
1004
|
+
notes_with_ambiguous_links: web.ambiguousLinkNotes,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
if (web.islands.length === 0) {
|
|
1008
|
+
return makeCheckResult(check, true, web.notes <= 1
|
|
1009
|
+
? `${web.notes} knowledge note(s); too few to form a web.`
|
|
1010
|
+
: `${web.connected} / ${web.notes} knowledge notes link another knowledge note.`, { knowledge_notes: web.notes, connected: web.connected, islands: 0 });
|
|
1011
|
+
}
|
|
1012
|
+
return makeCheckResult(check, false, `${web.islands.length} of ${web.notes} knowledge note(s) link no other knowledge note (disconnected island${web.islands.length === 1 ? "" : "s"}).`, {
|
|
1013
|
+
knowledge_notes: web.notes,
|
|
1014
|
+
connected: web.connected,
|
|
1015
|
+
islands: web.islands.length,
|
|
1016
|
+
island_notes: web.islands,
|
|
1017
|
+
});
|
|
1018
|
+
},
|
|
1019
|
+
/**
|
|
1020
|
+
* Whole-graph connectivity floor. The `knowledge_web_connectivity` check only
|
|
1021
|
+
* scans the `knowledge/` layer, and `summary_backlinks_present` only flags a
|
|
1022
|
+
* summary that a knowledge note CITES via source_refs but does not wikilink.
|
|
1023
|
+
* Neither sees a summary that no note cites at all — so a Context Graph can
|
|
1024
|
+
* pass readiness while the bulk of its `summaries/` notes are free-floating
|
|
1025
|
+
* islands no entrypoint or note reaches. This is the "no disconnected island
|
|
1026
|
+
* passing silently" rule applied to the WHOLE graph, not just the 7 knowledge
|
|
1027
|
+
* notes: every markdown note across `summaries/`, `knowledge/`, `artifacts/`,
|
|
1028
|
+
* and `home.md` must have undirected degree ≥ 1 in the note-link web. A note no
|
|
1029
|
+
* other note links AND that links no other note is a disconnected island and
|
|
1030
|
+
* fails readiness. Connectedness, not a count — one genuine inbound or outbound
|
|
1031
|
+
* edge is enough. By default the scan root is the Context Graph root; a Build
|
|
1032
|
+
* Plan may pass `params.graph_root` to narrow the floor to one layer (it may
|
|
1033
|
+
* only narrow inside the graph root, never escape it).
|
|
1034
|
+
*/
|
|
1035
|
+
graph_notes_connected(check, context) {
|
|
1036
|
+
// Default scope: the canonical CONTENT layers of the whole Context Graph
|
|
1037
|
+
// (summaries/, knowledge/, artifacts/, home.md) as one web — NOT the raw graph
|
|
1038
|
+
// root, which also holds runtime scaffolding (.interf/, .claude/, SKILL.md
|
|
1039
|
+
// docs, CLAUDE.md) that is not graph content and would inject false islands.
|
|
1040
|
+
// Intentionally spans all CONTENT layers, not `targetPath`: a knowledge note
|
|
1041
|
+
// or an entrypoint route may be the only thing connecting a summary, so scoping
|
|
1042
|
+
// to one layer in isolation would re-introduce the uncited-summary-island bug.
|
|
1043
|
+
const graphRoot = resolve(context.rootPath);
|
|
1044
|
+
let noteFiles;
|
|
1045
|
+
if (typeof check.params?.graph_root === "string" && check.params.graph_root.trim().length > 0) {
|
|
1046
|
+
const narrowed = resolve(graphRoot, check.params.graph_root);
|
|
1047
|
+
// May only NARROW inside the graph root; an override that escapes the root
|
|
1048
|
+
// is rejected so the floor cannot be pointed at a different, passing tree.
|
|
1049
|
+
if (narrowed !== graphRoot && !narrowed.startsWith(`${graphRoot}/`)) {
|
|
1050
|
+
return makeCheckResult(check, false, "params.graph_root resolves outside the Context Graph root; it may only narrow the connectivity floor to a path inside the graph.", { graph_root: check.params.graph_root });
|
|
1051
|
+
}
|
|
1052
|
+
// A narrowed scope scans that subtree's markdown notes directly (the caller
|
|
1053
|
+
// is naming a content path, so no scaffolding filter is applied beyond the
|
|
1054
|
+
// shared output-markdown filter).
|
|
1055
|
+
noteFiles = listMarkdownFiles(narrowed);
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
noteFiles = collectGraphContentNotes(graphRoot);
|
|
1059
|
+
}
|
|
1060
|
+
const web = analyzeNoteWeb(noteFiles, context);
|
|
1061
|
+
if (web.ambiguousLinkNotes.length > 0) {
|
|
1062
|
+
return makeCheckResult(check, false, `${web.ambiguousLinkNotes.length} note(s) link another note by an ambiguous basename two or more notes share; link the full graph path so the web edge can be verified.`, {
|
|
1063
|
+
graph_notes: web.notes,
|
|
1064
|
+
connected: web.connected,
|
|
1065
|
+
islands: web.islands.length,
|
|
1066
|
+
island_notes: web.islands,
|
|
1067
|
+
ambiguous_links: web.ambiguousLinkNotes.length,
|
|
1068
|
+
notes_with_ambiguous_links: web.ambiguousLinkNotes,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
if (web.islands.length === 0) {
|
|
1072
|
+
return makeCheckResult(check, true, web.notes <= 1
|
|
1073
|
+
? `${web.notes} note(s); too few to form a web.`
|
|
1074
|
+
: `${web.connected} / ${web.notes} notes are link-connected to another note (no islands).`, { graph_notes: web.notes, connected: web.connected, islands: 0 });
|
|
1075
|
+
}
|
|
1076
|
+
return makeCheckResult(check, false, `${web.islands.length} of ${web.notes} note(s) link no other note and are linked by none (disconnected island${web.islands.length === 1 ? "" : "s"}). Every Context Graph note — including every summary — must be reachable through the link web.`, {
|
|
1077
|
+
graph_notes: web.notes,
|
|
1078
|
+
connected: web.connected,
|
|
1079
|
+
islands: web.islands.length,
|
|
1080
|
+
island_notes: web.islands,
|
|
1081
|
+
});
|
|
1082
|
+
},
|
|
432
1083
|
wikilinks_valid(check, context) {
|
|
433
1084
|
const target = resolveTargetPath(check, context);
|
|
434
1085
|
if (!target) {
|
|
@@ -447,7 +1098,7 @@ const EVALUATORS = {
|
|
|
447
1098
|
}
|
|
448
1099
|
const phrases = checkPhrases(check);
|
|
449
1100
|
if (phrases.length === 0) {
|
|
450
|
-
return makeCheckResult(check, false, "
|
|
1101
|
+
return makeCheckResult(check, false, "Requested output diagnostic is missing forbidden text. Use `params.phrases: string[]` or `params.text: string`.");
|
|
451
1102
|
}
|
|
452
1103
|
try {
|
|
453
1104
|
const stats = statSync(target);
|
|
@@ -478,7 +1129,7 @@ const EVALUATORS = {
|
|
|
478
1129
|
}
|
|
479
1130
|
const phrases = checkPhrases(check);
|
|
480
1131
|
if (phrases.length === 0) {
|
|
481
|
-
return makeCheckResult(check, false, "
|
|
1132
|
+
return makeCheckResult(check, false, "Requested output diagnostic is missing required text. Use `params.phrases: string[]` or `params.text: string`.");
|
|
482
1133
|
}
|
|
483
1134
|
try {
|
|
484
1135
|
const stats = statSync(target);
|