kibi-cli 2.28.0 → 2.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/bin/kibi +12 -1
  2. package/dist/cli-command.d.ts +3 -0
  3. package/dist/cli-command.d.ts.map +1 -1
  4. package/dist/cli-command.js +11 -0
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +5 -2
  7. package/dist/commands/check.d.ts.map +1 -1
  8. package/dist/commands/check.js +4 -1
  9. package/dist/commands/engine.d.ts.map +1 -1
  10. package/dist/commands/engine.js +26 -2
  11. package/dist/commands/sync/manifest.d.ts.map +1 -1
  12. package/dist/commands/sync/manifest.js +6 -7
  13. package/dist/commands/sync.d.ts +10 -0
  14. package/dist/commands/sync.d.ts.map +1 -1
  15. package/dist/commands/sync.js +67 -3
  16. package/dist/engine.d.ts +11 -1
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +120 -34
  19. package/dist/extractors/manifest.d.ts.map +1 -1
  20. package/dist/extractors/manifest.js +3 -5
  21. package/dist/extractors/symbol-coordinates.d.ts.map +1 -1
  22. package/dist/extractors/symbol-coordinates.js +3 -5
  23. package/dist/extractors/symbols-coordinator.d.ts.map +1 -1
  24. package/dist/extractors/symbols-coordinator.js +3 -4
  25. package/dist/operations/mutation/symbol-granularity.d.ts.map +1 -1
  26. package/dist/operations/mutation/symbol-granularity.js +4 -4
  27. package/dist/operations/mutation/symbol-refresh.d.ts.map +1 -1
  28. package/dist/operations/mutation/symbol-refresh.js +4 -3
  29. package/dist/operations/mutation/validation.js +11 -0
  30. package/dist/plugins/approved-source-analyzers.generated.d.ts +6 -6
  31. package/dist/plugins/approved-source-analyzers.generated.js +6 -6
  32. package/dist/prolog/error-terms.d.ts.map +1 -1
  33. package/dist/prolog/error-terms.js +15 -9
  34. package/dist/prolog/store-lock.d.ts +11 -0
  35. package/dist/prolog/store-lock.d.ts.map +1 -1
  36. package/dist/prolog/store-lock.js +48 -1
  37. package/dist/traceability/commit-amend.d.ts +7 -0
  38. package/dist/traceability/commit-amend.d.ts.map +1 -0
  39. package/dist/traceability/commit-amend.js +85 -0
  40. package/dist/traceability/staged-inventory-drift.d.ts +12 -2
  41. package/dist/traceability/staged-inventory-drift.d.ts.map +1 -1
  42. package/dist/traceability/staged-inventory-drift.js +22 -5
  43. package/dist/utils/repo-relative-path.d.ts +7 -0
  44. package/dist/utils/repo-relative-path.d.ts.map +1 -1
  45. package/dist/utils/repo-relative-path.js +26 -0
  46. package/package.json +4 -4
  47. package/schema/entities.pl +2 -1
  48. package/schema/validation.pl +15 -0
@@ -15,6 +15,7 @@
15
15
  You should have received a copy of the GNU Affero General Public License
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
+ import { execFileSync } from "node:child_process";
18
19
  import { existsSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
19
20
  import { readFileSync } from "node:fs";
20
21
  import { basename, dirname, join } from "node:path";
@@ -61,6 +62,52 @@ export function isProcessAlive(pid) {
61
62
  return code === "EPERM";
62
63
  }
63
64
  }
65
+ function processInfo(pid) {
66
+ try {
67
+ if (process.platform === "linux") {
68
+ // `comm` is in parentheses; the fields after it are state, ppid, ...
69
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
70
+ const open = stat.indexOf("(");
71
+ const close = stat.lastIndexOf(")");
72
+ return {
73
+ command: stat.slice(open + 1, close),
74
+ parent: Number(stat.slice(close + 2).split(" ")[1]),
75
+ };
76
+ }
77
+ if (process.platform === "win32")
78
+ return null;
79
+ const [parent, ...command] = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 })
80
+ .trim()
81
+ .split(/\s+/);
82
+ return { parent: Number(parent), command: basename(command.join(" ")) };
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ /**
89
+ * The process to stop for a store lock holder. The lock records the engine's
90
+ * SWI-Prolog child; its parent (the daemon, or another Kibi process hosting
91
+ * the session) is what a user ends or stops.
92
+ */
93
+ // implements REQ-core-journaled-engine-persistence
94
+ export function lockHolderProcess(pid) {
95
+ const info = processInfo(pid);
96
+ if (info === null ||
97
+ !/^swipl/.test(info.command) ||
98
+ !Number.isInteger(info.parent) ||
99
+ info.parent <= 1)
100
+ return { pid };
101
+ return { pid: info.parent, prologPid: pid };
102
+ }
103
+ /** "pid N", naming the owning Kibi process when N is its SWI-Prolog child. */
104
+ // implements REQ-core-journaled-engine-persistence
105
+ export function describeLockHolderPid(pid) {
106
+ const holder = lockHolderProcess(pid);
107
+ return holder.prologPid === undefined
108
+ ? `pid ${holder.pid}`
109
+ : `pid ${holder.pid} (through its SWI-Prolog process, pid ${holder.prologPid})`;
110
+ }
64
111
  export function classifyStoreLockHolder(owner) {
65
112
  if (owner === null || typeof owner.pid !== "number")
66
113
  return "unknown";
@@ -356,7 +403,7 @@ export async function retryAttachAfterBreakingStaleLock(prolog, kbPath, failed)
356
403
  if (decision.action !== "break") {
357
404
  const holderSummary = storeLocked.owner === null
358
405
  ? decision.reason
359
- : `pid ${storeLocked.owner.pid ?? "?"} (${decision.reason})`;
406
+ : `${storeLocked.owner.pid === undefined ? "pid ?" : describeLockHolderPid(storeLocked.owner.pid)} (${decision.reason})`;
360
407
  return {
361
408
  ...failed,
362
409
  error: `${failed.error}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Whether the commit being made replaces a root commit (`git commit --amend`
3
+ * on a branch with one commit). Such a commit has no parent either, so the
4
+ * staged gate treats it as a first commit.
5
+ */
6
+ export declare function amendsRootCommit(workspaceRoot: string, headCommit: string | null): boolean;
7
+ //# sourceMappingURL=commit-amend.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commit-amend.d.ts","sourceRoot":"","sources":["../../src/traceability/commit-amend.ts"],"names":[],"mappings":"AAsEA;;;;GAIG;AAEH,wBAAgB,gBAAgB,CAC9B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,GAAG,IAAI,GACxB,OAAO,CAmBT"}
@@ -0,0 +1,85 @@
1
+ /*
2
+ Kibi — repo-local, per-branch, queryable long-term memory for software projects
3
+ Copyright (C) 2026 Piotr Franczyk
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ */
18
+ import { execFileSync } from "node:child_process";
19
+ import { readFileSync } from "node:fs";
20
+ import { basename } from "node:path";
21
+ /** How far up the process tree to look for the `git commit` running a hook. */
22
+ const MAX_ANCESTORS = 8;
23
+ function processArguments(pid) {
24
+ try {
25
+ if (process.platform === "linux") {
26
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
27
+ const parent = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
28
+ const argv = readFileSync(`/proc/${pid}/cmdline`, "utf8")
29
+ .split("\0")
30
+ .filter(Boolean);
31
+ return { parent, argv };
32
+ }
33
+ if (process.platform === "win32")
34
+ return null;
35
+ const [parent, ...argv] = execFileSync("ps", ["-o", "ppid=,args=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 })
36
+ .trim()
37
+ .split(/\s+/);
38
+ return { parent: Number(parent), argv };
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Whether this process runs under `git commit --amend`. Git does not tell a
46
+ * pre-commit hook that it is amending, so the hook's ancestors are read.
47
+ */
48
+ function runsUnderCommitAmend() {
49
+ let pid = process.ppid;
50
+ for (let depth = 0; depth < MAX_ANCESTORS && pid > 1; depth += 1) {
51
+ const info = processArguments(pid);
52
+ if (info === null)
53
+ return false;
54
+ const [program, ...rest] = info.argv;
55
+ if (program !== undefined && /^git(\.exe)?$/.test(basename(program)))
56
+ return rest.includes("commit") && rest.includes("--amend");
57
+ pid = info.parent;
58
+ }
59
+ return false;
60
+ }
61
+ /**
62
+ * Whether the commit being made replaces a root commit (`git commit --amend`
63
+ * on a branch with one commit). Such a commit has no parent either, so the
64
+ * staged gate treats it as a first commit.
65
+ */
66
+ // implements REQ-kibi-proposition-complete-ingestion
67
+ export function amendsRootCommit(workspaceRoot, headCommit) {
68
+ if (headCommit === null)
69
+ return false;
70
+ try {
71
+ const parents = execFileSync("git", ["rev-list", "--parents", "-n", "1", headCommit], {
72
+ cwd: workspaceRoot,
73
+ encoding: "utf8",
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ })
76
+ .trim()
77
+ .split(/\s+/);
78
+ if (parents.length > 1)
79
+ return false;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ return runsUnderCommitAmend();
85
+ }
@@ -13,12 +13,22 @@ export type StagedInventoryDriftViolation = Readonly<{
13
13
  * or in the base tree) whose inventory no longer matches their prose. The
14
14
  * next `kibi sync` refuses them under the proposition-complete boundary;
15
15
  * the gate applies the same test, so drift is caught at the commit that
16
- * introduces it instead of after a later merge. Requirements without a
17
- * contract are left to sync, which decides from its own baseline.
16
+ * introduces it instead of after a later merge. A requirement the base tree
17
+ * does not have yet is judged too, as sync judges a source it has never
18
+ * imported, unless the commit has no parent (a first commit, which sync
19
+ * imports as its semantic baseline). So is one whose statement (the prose
20
+ * its inventory covers) differs from the base tree's, as sync judges a
21
+ * source whose semantic hash changed. Other requirements without a contract,
22
+ * such as a Context-only edit, are left to sync's baseline.
18
23
  */
19
24
  export declare function stagedInventoryDriftViolations(input: {
20
25
  stagedPaths: ReadonlySet<string>;
21
26
  snapshotEntities: readonly ExtractionResult[];
22
27
  readBase: (path: string) => string | null;
28
+ /**
29
+ * False for a first commit, or an amend of it: sync grandfathers its sources
30
+ * as a baseline, so only declared contracts are judged.
31
+ */
32
+ judgeNewRequirements?: boolean;
23
33
  }): StagedInventoryDriftViolation[];
24
34
  //# sourceMappingURL=staged-inventory-drift.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"staged-inventory-drift.d.ts","sourceRoot":"","sources":["../../src/traceability/staged-inventory-drift.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAKlE,iFAAiF;AAEjF,eAAO,MAAM,2BAA2B,6BAA6B,CAAC;AAGtE,MAAM,MAAM,6BAA6B,GAAG,QAAQ,CAAC;IACnD,IAAI,EAAE,OAAO,2BAA2B,CAAC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3B,CAAC,CAAC;AAgCH;;;;;;;GAOG;AAEH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE;IACpD,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACjC,gBAAgB,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC9C,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CAC3C,GAAG,6BAA6B,EAAE,CA8BlC"}
1
+ {"version":3,"file":"staged-inventory-drift.d.ts","sourceRoot":"","sources":["../../src/traceability/staged-inventory-drift.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAKlE,iFAAiF;AAEjF,eAAO,MAAM,2BAA2B,6BAA6B,CAAC;AAGtE,MAAM,MAAM,6BAA6B,GAAG,QAAQ,CAAC;IACnD,IAAI,EAAE,OAAO,2BAA2B,CAAC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3B,CAAC,CAAC;AA0CH;;;;;;;;;;;;GAYG;AAEH,wBAAgB,8BAA8B,CAAC,KAAK,EAAE;IACpD,WAAW,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACjC,gBAAgB,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC9C,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAC1C;;;OAGG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC,GAAG,6BAA6B,EAAE,CAsClC"}
@@ -34,6 +34,11 @@ function payloadOf(result) {
34
34
  relationships: result.relationships,
35
35
  };
36
36
  }
37
+ /** The hash of the prose a requirement's inventory must cover. */
38
+ function semanticSourceHashOf(result) {
39
+ const payload = payloadOf(result);
40
+ return validateSemanticInventoryBoundary(payload, result.relationships, analyzeSemanticAdvisorInput({ payload }).receipt).sourceHash;
41
+ }
37
42
  function baseResult(path, readBase) {
38
43
  const content = readBase(path);
39
44
  if (content === null)
@@ -50,8 +55,13 @@ function baseResult(path, readBase) {
50
55
  * or in the base tree) whose inventory no longer matches their prose. The
51
56
  * next `kibi sync` refuses them under the proposition-complete boundary;
52
57
  * the gate applies the same test, so drift is caught at the commit that
53
- * introduces it instead of after a later merge. Requirements without a
54
- * contract are left to sync, which decides from its own baseline.
58
+ * introduces it instead of after a later merge. A requirement the base tree
59
+ * does not have yet is judged too, as sync judges a source it has never
60
+ * imported, unless the commit has no parent (a first commit, which sync
61
+ * imports as its semantic baseline). So is one whose statement (the prose
62
+ * its inventory covers) differs from the base tree's, as sync judges a
63
+ * source whose semantic hash changed. Other requirements without a contract,
64
+ * such as a Context-only edit, are left to sync's baseline.
55
65
  */
56
66
  // implements REQ-kibi-proposition-complete-ingestion
57
67
  export function stagedInventoryDriftViolations(input) {
@@ -68,16 +78,23 @@ export function stagedInventoryDriftViolations(input) {
68
78
  if (boundary.errors.length === 0)
69
79
  continue;
70
80
  const base = baseResult(source, input.readBase);
71
- const enforce = advertisesContract(result.entity) ||
81
+ const contract = advertisesContract(result.entity) ||
72
82
  (base !== null && advertisesContract(base.entity));
73
- if (!enforce)
83
+ const judgeUncontracted = input.judgeNewRequirements ?? true;
84
+ const isNew = input.readBase(source) === null && judgeUncontracted;
85
+ const restated = judgeUncontracted &&
86
+ base !== null &&
87
+ semanticSourceHashOf(base) !== boundary.sourceHash;
88
+ if (!contract && !isNew && !restated)
74
89
  continue;
75
90
  violations.push({
76
91
  rule: STAGED_INVENTORY_DRIFT_RULE,
77
92
  entityId: result.entity.id,
78
93
  source,
79
94
  errors: boundary.errors,
80
- message: `${result.entity.id} (${source}): the semantic inventory does not match the requirement's prose, so the next kibi sync rejects it: ${boundary.errors.join("; ")}. Run 'kibi migrate' to re-derive it, or kb_model mode analyze with the complete prose and keep its inventory contract.`,
95
+ message: contract
96
+ ? `${result.entity.id} (${source}): the semantic inventory does not match the requirement's prose, so the next kibi sync rejects it: ${boundary.errors.join("; ")}. Run 'kibi migrate' to re-derive it, or kb_model mode analyze with the complete prose and keep its inventory contract.`
97
+ : `${result.entity.id} (${source}): ${isNew ? "a new requirement" : "a requirement whose statement changes"} needs a semantic inventory that matches its prose, so the next kibi sync rejects it: ${boundary.errors.join("; ")}. Run kb_model mode analyze (CLI: semantic-advisor) with its id and complete semantic_text, then write the returned inventory contract into it (kb_upsert with semantic_inventory, semantic_inventory_version, semantic_source_field, semantic_source_hash and logic_claims).`,
81
98
  });
82
99
  }
83
100
  return violations.sort((left, right) => left.entityId.localeCompare(right.entityId));
@@ -15,4 +15,11 @@
15
15
  * (the workspace snapshot), the caller compares it in NFC.
16
16
  */
17
17
  export declare function normalizeRepoRelativePath(workspaceRoot: string, candidate: string): string | null;
18
+ /**
19
+ * Resolve a recorded source path to an absolute path that is lexically (and,
20
+ * when the file exists, via realpath) confined under `workspaceRoot`.
21
+ * Returns null for escapes, empty input, or symlink targets outside the root.
22
+ * Callers must treat null as "do not read".
23
+ */
24
+ export declare function resolveWorkspaceSourceAbsolute(workspaceRoot: string, sourceFile: string): string | null;
18
25
  //# sourceMappingURL=repo-relative-path.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"repo-relative-path.d.ts","sourceRoot":"","sources":["../../src/utils/repo-relative-path.ts"],"names":[],"mappings":"AAoBA;;;;;;;;;;;;;;;GAeG;AAEH,wBAAgB,yBAAyB,CACvC,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,IAAI,CAsBf"}
1
+ {"version":3,"file":"repo-relative-path.d.ts","sourceRoot":"","sources":["../../src/utils/repo-relative-path.ts"],"names":[],"mappings":"AAqBA;;;;;;;;;;;;;;;GAeG;AAEH,wBAAgB,yBAAyB,CACvC,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,IAAI,CAsBf;AAED;;;;;GAKG;AAEH,wBAAgB,8BAA8B,CAC5C,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,GACjB,MAAM,GAAG,IAAI,CAcf"}
@@ -15,6 +15,7 @@
15
15
  You should have received a copy of the GNU Affero General Public License
16
16
  along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
  */
18
+ import { realpathSync } from "node:fs";
18
19
  import path from "node:path";
19
20
  /**
20
21
  * Canonical repository-relative form of a recorded path, as proof freshness
@@ -56,3 +57,28 @@ export function normalizeRepoRelativePath(workspaceRoot, candidate) {
56
57
  }
57
58
  return normalized;
58
59
  }
60
+ /**
61
+ * Resolve a recorded source path to an absolute path that is lexically (and,
62
+ * when the file exists, via realpath) confined under `workspaceRoot`.
63
+ * Returns null for escapes, empty input, or symlink targets outside the root.
64
+ * Callers must treat null as "do not read".
65
+ */
66
+ // implements REQ-symbol-sourcefile-workspace-confinement
67
+ export function resolveWorkspaceSourceAbsolute(workspaceRoot, sourceFile) {
68
+ const relative = normalizeRepoRelativePath(workspaceRoot, sourceFile);
69
+ if (relative === null)
70
+ return null;
71
+ const absolute = path.resolve(workspaceRoot, relative);
72
+ try {
73
+ const realRoot = realpathSync.native(path.resolve(workspaceRoot));
74
+ const realFile = realpathSync.native(absolute);
75
+ const confined = path.relative(realRoot, realFile);
76
+ if (confined.startsWith("..") || path.isAbsolute(confined))
77
+ return null;
78
+ return realFile;
79
+ }
80
+ catch {
81
+ // Missing path: lexical confinement from normalizeRepoRelativePath is enough.
82
+ return absolute;
83
+ }
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-cli",
3
- "version": "2.28.0",
3
+ "version": "2.29.0",
4
4
  "type": "module",
5
5
  "description": "Kibi CLI for knowledge base management",
6
6
  "engines": {
@@ -235,13 +235,13 @@
235
235
  "fast-glob": "^3.2.12",
236
236
  "gray-matter": "^4.0.3",
237
237
  "js-yaml": "^4.1.0",
238
- "kibi-core": "^0.22.3",
239
- "kibi-plugin-builtin": "^0.5.2",
238
+ "kibi-core": "^0.22.4",
239
+ "kibi-plugin-builtin": "^0.5.3",
240
240
  "kibi-plugin-sdk": "^0.5.0",
241
241
  "kibi-swipl": "^1.0.1",
242
242
  "saxes": "^6.0.0",
243
243
  "ignore": "^5.3.0",
244
- "yaml": "^2.9.0"
244
+ "yaml": "^2.9.1"
245
245
  },
246
246
  "devDependencies": {
247
247
  "@types/node": "latest",
@@ -29,7 +29,8 @@ entity_property(_, priority, atom).
29
29
  entity_property(_, severity, atom).
30
30
  entity_property(_, links, list).
31
31
  entity_property(_, text_ref, uri).
32
- entity_property(_, sourceFile, uri).
32
+ % Workspace-relative source paths only (no absolute / .. escapes).
33
+ entity_property(_, sourceFile, workspace_relpath).
33
34
  % Provenance of any authored entity: a JSON object (kind, ref, approved_by,
34
35
  % recorded_at) naming who authored it and on what authority.
35
36
  entity_property(_, origin, list_or_json).
@@ -50,11 +50,26 @@ check_kind(datetime, V) :- string(V). % accept ISO strings for now
50
50
  check_kind(list, V) :- is_list(V).
51
51
  check_kind(list_or_json, V) :- is_list(V) ; string(V) ; atom(V).
52
52
  check_kind(uri, V) :- string(V).
53
+ % Non-empty workspace-relative path: reject absolute forms and `..` segments.
54
+ check_kind(workspace_relpath, V) :-
55
+ string(V),
56
+ string_length(V, Len),
57
+ Len > 0,
58
+ \+ workspace_relpath_absolute(V),
59
+ split_string(V, "/\\", "", Parts),
60
+ \+ member("..", Parts).
53
61
  check_kind(integer, V) :- integer(V).
54
62
  check_kind(number, V) :- number(V).
55
63
  check_kind(boolean, true).
56
64
  check_kind(boolean, false).
57
65
 
66
+ workspace_relpath_absolute(V) :- sub_string(V, 0, 1, _, "/").
67
+ workspace_relpath_absolute(V) :-
68
+ string_length(V, Len),
69
+ Len >= 2,
70
+ sub_string(V, 1, 1, _, ":").
71
+ workspace_relpath_absolute(V) :- sub_string(V, 0, 2, _, "\\\\").
72
+
58
73
  % Fallback false
59
74
  check_kind(_, _) :- fail.
60
75