@nimiplatform/nimi-coding 0.3.1 → 0.4.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 (118) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/README.md +285 -41
  4. package/README.zh-CN.md +294 -31
  5. package/bin/nimicoding.mjs +1 -1
  6. package/cli/commands/authority.mjs +849 -0
  7. package/cli/commands/clear.mjs +27 -7
  8. package/cli/commands/doctor.mjs +10 -1
  9. package/cli/commands/start.mjs +23 -27
  10. package/cli/commands/sync.mjs +21 -101
  11. package/cli/constants.mjs +7 -31
  12. package/cli/dev/check-authority-surface.mjs +173 -0
  13. package/cli/dev/check-product-conformance.mjs +917 -0
  14. package/cli/help.mjs +10 -17
  15. package/cli/index.mjs +6 -47
  16. package/cli/lib/authority/anchors.mjs +491 -0
  17. package/cli/lib/authority/audit.mjs +490 -0
  18. package/cli/lib/authority/closed-sets.mjs +646 -0
  19. package/cli/lib/authority/compile.mjs +110 -0
  20. package/cli/lib/authority/consumers.mjs +470 -0
  21. package/cli/lib/authority/diagnostics.mjs +87 -0
  22. package/cli/lib/authority/diff.mjs +287 -0
  23. package/cli/lib/authority/discover.mjs +447 -0
  24. package/cli/lib/authority/evidence-snapshot.mjs +1 -0
  25. package/cli/lib/authority/evidence.mjs +1432 -0
  26. package/cli/lib/authority/format.mjs +197 -0
  27. package/cli/lib/authority/git-snapshot.mjs +1065 -0
  28. package/cli/lib/authority/graph.mjs +351 -0
  29. package/cli/lib/authority/impact.mjs +252 -0
  30. package/cli/lib/authority/lexicon.mjs +55 -0
  31. package/cli/lib/authority/query.mjs +162 -0
  32. package/cli/lib/authority/repository-corpus.mjs +37 -0
  33. package/cli/lib/authority/repository-inventory.mjs +34 -0
  34. package/cli/lib/authority/review.mjs +222 -0
  35. package/cli/lib/authority/sarif.mjs +181 -0
  36. package/cli/lib/authority/scope-bindings.mjs +225 -0
  37. package/cli/lib/authority/source-map.mjs +105 -0
  38. package/cli/lib/authority/source-markdown.mjs +167 -0
  39. package/cli/lib/authority/source-yaml.mjs +312 -0
  40. package/cli/lib/authority/surface.mjs +79 -0
  41. package/cli/lib/authority/terms.mjs +421 -0
  42. package/cli/lib/authority/tracked-text.mjs +70 -0
  43. package/cli/lib/authority/validate.mjs +308 -0
  44. package/cli/lib/bootstrap.mjs +55 -71
  45. package/cli/lib/doctor.mjs +40 -123
  46. package/cli/lib/entrypoints.mjs +103 -105
  47. package/cli/lib/fs-helpers.mjs +139 -15
  48. package/cli/lib/internal/governance/config.mjs +0 -36
  49. package/cli/lib/shared.mjs +8 -21
  50. package/cli/lib/sync.mjs +81 -92
  51. package/cli/lib/value-helpers.mjs +0 -26
  52. package/cli/lib/yaml-helpers.mjs +1 -124
  53. package/cli/seeds/bootstrap.mjs +31 -87
  54. package/cli/seeds/seed-policy.yaml +9 -63
  55. package/contracts/authority-evidence-bindings.schema.yaml +53 -0
  56. package/contracts/authority-evidence-probe-results.schema.yaml +38 -0
  57. package/contracts/authority-impact-dispositions.schema.yaml +13 -0
  58. package/contracts/authority-scope-bindings.schema.yaml +15 -0
  59. package/contracts/authority-source.schema.yaml +64 -0
  60. package/contracts/authority-verifier-bindings.schema.yaml +20 -0
  61. package/methodology/authority-authoring.yaml +144 -0
  62. package/methodology/core.yaml +31 -26
  63. package/package.json +10 -6
  64. package/spec/authority-anchors.yaml +99 -0
  65. package/spec/authority-audit.yaml +85 -0
  66. package/spec/authority-authoring.yaml +150 -0
  67. package/spec/authority-closed-sets.yaml +65 -0
  68. package/spec/authority-consumers.yaml +70 -0
  69. package/spec/authority-context.yaml +50 -0
  70. package/spec/authority-discovery.yaml +119 -0
  71. package/spec/authority-evidence.yaml +200 -0
  72. package/spec/authority-graph.yaml +59 -0
  73. package/spec/authority-impact.yaml +84 -0
  74. package/spec/authority-review.yaml +89 -0
  75. package/spec/authority-terms.yaml +59 -0
  76. package/spec/product-scope.yaml +36 -23
  77. package/cli/commands/blueprint-audit.mjs +0 -91
  78. package/cli/commands/classify-spec-tree.mjs +0 -5
  79. package/cli/commands/generate-spec-derived-docs.mjs +0 -151
  80. package/cli/commands/generate-spec-migration-plan.mjs +0 -30
  81. package/cli/commands/surface-validator-command.mjs +0 -49
  82. package/cli/commands/validate-domain-admission.mjs +0 -5
  83. package/cli/commands/validate-guidance-bodies.mjs +0 -5
  84. package/cli/commands/validate-placement.mjs +0 -5
  85. package/cli/commands/validate-projection-edges.mjs +0 -5
  86. package/cli/commands/validate-spec-audit.mjs +0 -27
  87. package/cli/commands/validate-spec-governance.mjs +0 -144
  88. package/cli/commands/validate-spec-tree.mjs +0 -27
  89. package/cli/commands/validate-table-family.mjs +0 -5
  90. package/cli/commands/validate-tracked-output-admission.mjs +0 -5
  91. package/cli/lib/blueprint-audit.mjs +0 -363
  92. package/cli/lib/contracts.mjs +0 -21
  93. package/cli/lib/internal/contracts-loaders.mjs +0 -47
  94. package/cli/lib/internal/contracts-parse.mjs +0 -192
  95. package/cli/lib/internal/governance/runner.mjs +0 -35
  96. package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
  97. package/cli/lib/internal/validators-shared.mjs +0 -12
  98. package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
  99. package/cli/lib/internal/validators-spec.mjs +0 -380
  100. package/cli/lib/validators.mjs +0 -30
  101. package/config/bootstrap.yaml +0 -6
  102. package/config/spec-generation-inputs.yaml +0 -41
  103. package/contracts/domain-admission.schema.yaml +0 -56
  104. package/contracts/migration-inventory.schema.yaml +0 -15
  105. package/contracts/negative-fixtures.yaml +0 -26
  106. package/contracts/placement-contract.schema.yaml +0 -127
  107. package/contracts/projection-edge.schema.yaml +0 -46
  108. package/contracts/shared-enums.yaml +0 -63
  109. package/contracts/spec-generation-audit.schema.yaml +0 -46
  110. package/contracts/spec-generation-inputs.schema.yaml +0 -43
  111. package/contracts/spec-layout.schema.yaml +0 -32
  112. package/contracts/surface-taxonomy.schema.yaml +0 -139
  113. package/contracts/table-family.schema.yaml +0 -133
  114. package/contracts/tracked-output-admission.schema.yaml +0 -17
  115. package/methodology/four-closure-policy.yaml +0 -20
  116. package/methodology/role-separation-policy.yaml +0 -22
  117. package/methodology/spec-reconstruction.yaml +0 -69
  118. package/spec/_meta/spec-tree-model.yaml +0 -44
@@ -1,11 +1,8 @@
1
1
  import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { SPEC_GENERATION_AUDIT_REF } from "../constants.mjs";
5
4
  import { getBootstrapSeedEntries } from "../seeds/bootstrap.mjs";
6
- import { inspectBootstrapCompatibility } from "./bootstrap.mjs";
7
- import { loadSpecGenerationInputsConfig } from "./contracts.mjs";
8
- import { pathExists, readTextIfFile } from "./fs-helpers.mjs";
5
+ import { pathExists, preflightManagedProjectPaths } from "./fs-helpers.mjs";
9
6
  import { runSeedSync, SYNC_MODE } from "./sync.mjs";
10
7
  import { localize, styleHeading, styleLabel, styleStatus } from "./ui.mjs";
11
8
 
@@ -15,111 +12,47 @@ function check(id, ok, severity, detail) {
15
12
 
16
13
  async function directoryHasFiles(root) {
17
14
  const info = await pathExists(root);
18
- if (!info || !info.isDirectory()) return false;
19
- const entries = await readdir(root, { withFileTypes: true });
20
- return entries.length > 0;
21
- }
22
-
23
- async function managedEntrypoints(projectRoot) {
24
- const refs = [];
25
- for (const ref of ["AGENTS.md", "CLAUDE.md"]) {
26
- const text = await readTextIfFile(path.join(projectRoot, ref));
27
- if (text?.includes("nimicoding:managed:")) refs.push(ref);
28
- }
29
- return refs;
15
+ if (!info?.isDirectory()) return false;
16
+ return (await readdir(root)).length > 0;
30
17
  }
31
18
 
32
19
  export async function inspectDoctorState(projectRoot) {
33
- const checks = [];
34
- const bootstrap = await inspectBootstrapCompatibility(projectRoot);
35
- checks.push(check(
36
- "bootstrap_contract",
37
- bootstrap.status === "supported",
38
- "error",
39
- bootstrap.status === "supported"
40
- ? "bootstrap contract is supported"
41
- : "bootstrap contract is missing or unsupported",
42
- ));
43
-
44
- let seedSync = null;
45
- if (bootstrap.status === "supported") {
46
- seedSync = await runSeedSync(projectRoot, SYNC_MODE.CHECK);
47
- checks.push(check(
48
- "managed_projection",
49
- seedSync.ok,
20
+ await preflightManagedProjectPaths(projectRoot, { includeSpec: true });
21
+ const managedProjection = await runSeedSync(projectRoot, SYNC_MODE.CHECK);
22
+ const specPresent = await directoryHasFiles(path.join(projectRoot, ".nimi/spec"));
23
+ const checks = [
24
+ check(
25
+ "managed_surfaces",
26
+ managedProjection.ok,
50
27
  "error",
51
- seedSync.ok
52
- ? `all ${seedSync.summary.total} package-managed files are aligned`
53
- : `${seedSync.checkFailures.length} package-managed files are missing or drifted`,
28
+ managedProjection.ok
29
+ ? "exact package projection and managed instruction blocks are aligned"
30
+ : `${managedProjection.checkFailures.length} exact managed or deprecated surface issue(s) require attention`,
31
+ ),
32
+ check(
33
+ "canonical_authority",
34
+ true,
35
+ "info",
36
+ specPresent
37
+ ? "canonical authority is present; run nimicoding authority check .nimi/spec for admission"
38
+ : "canonical authority has not been authored; the project owns all product semantics under .nimi/spec",
39
+ ),
40
+ ];
41
+ if (process.platform === "win32") {
42
+ checks.push(check(
43
+ "snapshot_no_follow_capability",
44
+ false,
45
+ "info",
46
+ "win32 does not expose O_NOFOLLOW/O_DIRECTORY; snapshot no-follow hardening is downgraded to surrounding lstat/realpath validation",
54
47
  ));
55
48
  }
56
-
57
- const generationInputs = await loadSpecGenerationInputsConfig(projectRoot);
58
- checks.push(check(
59
- "spec_generation_inputs",
60
- generationInputs.ok && generationInputs.mode === "class_filtered",
61
- "error",
62
- generationInputs.ok && generationInputs.mode === "class_filtered"
63
- ? "spec generation inputs use class-filtered authority"
64
- : "spec generation inputs are missing or invalid",
65
- ));
66
-
67
- const specRoot = path.join(projectRoot, ".nimi", "spec");
68
- const specPresent = await directoryHasFiles(specRoot);
69
- checks.push(check(
70
- "spec_tree",
71
- true,
72
- "info",
73
- specPresent
74
- ? "canonical spec tree is present; conformance is checked by validate-spec-tree or project-specific validators"
75
- : "canonical spec tree has not been constructed yet",
76
- ));
77
-
78
- const auditPath = path.join(projectRoot, SPEC_GENERATION_AUDIT_REF);
79
- const auditPresent = Boolean(await pathExists(auditPath));
80
- checks.push(check(
81
- "spec_generation_audit",
82
- true,
83
- "info",
84
- auditPresent
85
- ? "spec generation audit is present; conformance is checked by validate-spec-audit"
86
- : "spec generation audit is not present",
87
- ));
88
-
89
- const entrypoints = await managedEntrypoints(projectRoot);
90
- checks.push(check(
91
- "entrypoint_integration",
92
- true,
93
- "info",
94
- entrypoints.length > 0
95
- ? `managed methodology guidance is present in ${entrypoints.join(", ")}`
96
- : "managed methodology guidance is not installed",
97
- ));
98
-
99
49
  const seedEntries = await getBootstrapSeedEntries();
100
- const ok = checks.every((entry) => entry.severity !== "error");
101
-
102
50
  return {
103
- ok,
51
+ ok: checks.every((entry) => entry.severity !== "error"),
104
52
  projectRoot,
105
- bootstrap,
106
- managedProjection: seedSync,
107
- generationInputs: {
108
- ok: generationInputs.ok,
109
- mode: generationInputs.mode ?? null,
110
- canonicalTargetRoot: generationInputs.canonicalTargetRoot ?? null,
111
- },
112
- spec: {
113
- present: specPresent,
114
- ok: null,
115
- summary: null,
116
- },
117
- generationAudit: {
118
- present: auditPresent,
119
- ok: null,
120
- summary: null,
121
- },
122
- entrypoints,
53
+ bootstrap: { status: "exact_projection_registry" },
54
+ managedProjection,
55
+ spec: { present: specPresent, ok: null, summary: null },
123
56
  managedFileCount: seedEntries.length,
124
57
  checks,
125
58
  };
@@ -131,33 +64,17 @@ export function formatDoctorResult(result, options = {}) {
131
64
  "",
132
65
  styleLabel(localize("Summary:", "摘要:")),
133
66
  ` - ${localize("status", "状态")}: ${styleStatus(result.ok ? "ok" : "needs_attention")}`,
134
- ` - bootstrap: ${result.bootstrap.status}`,
135
- ` - managed_projection: ${result.managedProjection?.ok === true ? "aligned" : "invalid"}`,
136
- ` - spec: ${!result.spec.present ? "not_constructed" : "present_not_validated"}`,
137
- ` - generation_audit: ${!result.generationAudit.present ? "not_present" : "present_not_validated"}`,
67
+ ` - projection_policy: ${result.bootstrap.status}`,
68
+ ` - managed_surfaces: ${result.managedProjection.ok ? "aligned" : "invalid"}`,
69
+ ` - canonical_authority: ${result.spec.present ? "present_not_validated" : "not_authored"}`,
138
70
  "",
139
71
  styleLabel(localize("Checks:", "检查项:")),
140
72
  ];
141
-
142
- const visibleChecks = options.verbose
143
- ? result.checks
144
- : result.checks.filter((entry) => entry.severity !== "ok" || !entry.ok);
145
- if (visibleChecks.length === 0) {
146
- lines.push(` - ${localize("No blocking issues found.", "没有发现阻塞问题。")}`);
147
- } else {
148
- for (const entry of visibleChecks) {
149
- const marker = entry.ok ? "info" : entry.severity;
150
- lines.push(` - [${marker}] ${entry.detail}`);
151
- }
152
- }
153
-
73
+ const visible = options.verbose ? result.checks : result.checks.filter((entry) => entry.severity !== "ok" || !entry.ok);
74
+ if (visible.length === 0) lines.push(` - ${localize("No blocking integration issues found.", "没有发现阻塞性的集成问题。")}`);
75
+ else for (const entry of visible) lines.push(` - [${entry.ok ? "info" : entry.severity}] ${entry.detail}`);
154
76
  if (!result.spec.present) {
155
- lines.push(
156
- "",
157
- styleLabel(localize("Next:", "下一步:")),
158
- " - Build canonical product authority under .nimi/spec, then run nimicoding validate-spec-tree.",
159
- );
77
+ lines.push("", styleLabel(localize("Next:", "下一步:")), " - Author canonical authority under .nimi/spec, format changed files, then run nimicoding authority check .nimi/spec.");
160
78
  }
161
-
162
79
  return `${lines.join("\n")}\n`;
163
80
  }
@@ -1,4 +1,4 @@
1
- import { readFile, rm, writeFile } from "node:fs/promises";
1
+ import { writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import {
@@ -7,101 +7,79 @@ import {
7
7
  CLAUDE_BEGIN,
8
8
  CLAUDE_END,
9
9
  } from "../constants.mjs";
10
- import { pathExists } from "./fs-helpers.mjs";
10
+ import { pathExists, preflightManagedProjectPaths, readUtf8FileFatal } from "./fs-helpers.mjs";
11
11
 
12
- function managedAgentsBlock() {
12
+ export class ManagedBlockError extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "ManagedBlockError";
16
+ }
17
+ }
18
+
19
+ function authorityAuthoringLines() {
13
20
  return [
14
- AGENTS_BEGIN,
15
- "# Nimi Coding Managed Block",
16
- "",
17
- "- Product and repository authority lives under `/.nimi/spec/**`.",
18
- "- Read `/.nimi/spec/INDEX.md` first, then the affected kernel contracts and tables.",
19
- "- Read `/.nimi/methodology/**` for reasoning principles and `/.nimi/contracts/**` for spec construction and validation contracts.",
20
- "- Treat `/.nimi/{config,contracts,methodology}/**` as package-managed governance support, never as product authority.",
21
- "- Keep generation evidence under `/.nimi/local/state/spec-generation/**`; it must not become product truth.",
22
- "- Do not treat this managed block as a replacement for project-specific rules.",
23
- AGENTS_END,
24
- ].join("\n");
21
+ "- Product authority lives under `.nimi/spec/**`.",
22
+ "- For canonical authority authoring, read only `.nimi/methodology/authority-authoring.yaml`, the affected authority files or bounded task context, and CLI diagnostics.",
23
+ "- Use `nimicoding authority context <path> <id> --max-units <n> --max-bytes <n> --json` only for the complete declared outgoing interpretation closure; it is not complete task context, and failure never permits guessed or partial context.",
24
+ "- Use `nimicoding authority diff` and `authority impact` with explicit `--max-bytes`; impact reports declared review obligations and does not prove implementation, consumers, or tests are synchronized.",
25
+ "- Under `.nimi/spec/**`, author only closed multi-unit `*.authority.yaml` containers or single-unit `*.authority.md`; historical document formats are unsupported and never inferred.",
26
+ "- Run `nimicoding authority fmt` on each changed file, then `nimicoding authority check` on the complete authority input set.",
27
+ "- Never bypass a failure with inferred or fallback semantics; choose repair values only from product/task authority.",
28
+ "- Keep derived and verification evidence under `.nimi/local/**`; it is never product authority.",
29
+ ];
30
+ }
31
+
32
+ function managedAgentsBlock() {
33
+ return [AGENTS_BEGIN, "# Nimi Coding Managed Block", "", ...authorityAuthoringLines(), AGENTS_END].join("\n");
25
34
  }
26
35
  function managedClaudeBlock() {
27
- return [
28
- CLAUDE_BEGIN,
29
- "# Nimi Coding Managed Block",
30
- "",
31
- "Authority order:",
32
- "1. `/.nimi/spec/**`",
33
- "2. `/.nimi/methodology/**`",
34
- "3. `/.nimi/contracts/**`",
35
- "4. `/.nimi/config/**`",
36
- "5. repository-local AI entry files",
37
- "",
38
- "Nimi Coding owns methodology, canonical spec construction, and deterministic validation only.",
39
- "Keep local generation evidence outside product authority.",
40
- CLAUDE_END,
41
- ].join("\n");
42
- }
43
-
44
- function escapeRegExp(value) {
45
- return value.replace(/[.*+?^$()|[\]{}\\]/g, "\\$&");
46
- }
47
-
48
- function blockPattern(begin, end, leading = false) {
49
- return new RegExp(
50
- `${leading ? "(?:\\n\\n)?" : ""}${escapeRegExp(begin)}[\\s\\S]*?${escapeRegExp(end)}${leading ? "(?:\\n)?" : ""}`,
51
- );
52
- }
53
-
54
- function upsertManagedBlock(existing, begin, end, block) {
55
- const pattern = blockPattern(begin, end);
56
- if (pattern.test(existing)) {
57
- return existing.replace(pattern, block);
36
+ return [CLAUDE_BEGIN, "# Nimi Coding Managed Block", "", ...authorityAuthoringLines(), CLAUDE_END].join("\n");
37
+ }
38
+
39
+ function occurrenceIndexes(text, marker) {
40
+ const indexes = [];
41
+ let offset = 0;
42
+ while (offset <= text.length) {
43
+ const index = text.indexOf(marker, offset);
44
+ if (index === -1) break;
45
+ indexes.push(index);
46
+ offset = index + marker.length;
58
47
  }
59
- const prefix = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
60
- return `${existing}${prefix}${block}\n`;
48
+ return indexes;
61
49
  }
62
50
 
63
- function removeManagedBlock(existing, begin, end) {
64
- const pattern = blockPattern(begin, end, true);
65
- if (!pattern.test(existing)) {
66
- return { changed: false, next: existing, deleteFile: false };
51
+ export function analyzeManagedBlockText(existing, begin, end, expectedBlock, label = "managed block") {
52
+ const begins = occurrenceIndexes(existing, begin);
53
+ const ends = occurrenceIndexes(existing, end);
54
+ if (begins.length === 0 && ends.length === 0) return { state: "missing", begin: -1, end: -1, exact: false };
55
+ if (begins.length !== 1 || ends.length !== 1) {
56
+ throw new ManagedBlockError(`${label} markers must contain exactly one ordered, non-nested begin/end pair`);
67
57
  }
68
- const next = existing
69
- .replace(pattern, "\n")
70
- .replace(/\n{3,}/g, "\n\n")
71
- .replace(/^\n+/, "")
72
- .trimEnd();
58
+ if (begins[0] >= ends[0]) throw new ManagedBlockError(`${label} markers must contain exactly one ordered, non-nested begin/end pair`);
59
+ const endOffset = ends[0] + end.length;
60
+ const actualBlock = existing.slice(begins[0], endOffset);
73
61
  return {
74
- changed: true,
75
- next: next.length === 0 ? "" : `${next}\n`,
76
- deleteFile: next.length === 0,
62
+ state: actualBlock === expectedBlock ? "exact" : "drifted",
63
+ begin: begins[0],
64
+ end: endOffset,
65
+ exact: actualBlock === expectedBlock,
77
66
  };
78
67
  }
79
68
 
80
- function computeTextFileUpdate(existing, block, begin, end, header) {
81
- const base = existing ?? header;
82
- const next = upsertManagedBlock(base, begin, end, block);
83
- return { changed: next !== base, next };
84
- }
85
-
86
- async function upsertTextFile(filePath, block, begin, end, header) {
87
- const existing = (await pathExists(filePath)) ? await readFile(filePath, "utf8") : header;
88
- const computed = computeTextFileUpdate(existing, block, begin, end, header);
89
- if (!computed.changed) return false;
90
- await writeFile(filePath, computed.next, "utf8");
91
- return true;
69
+ function upsertManagedBlock(existing, analysis, block) {
70
+ if (analysis.state === "missing") {
71
+ const prefix = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
72
+ return `${existing}${prefix}${block}\n`;
73
+ }
74
+ return `${existing.slice(0, analysis.begin)}${block}${existing.slice(analysis.end)}`;
92
75
  }
93
76
 
94
- async function removeManagedTextFile(filePath, begin, end, emptyFallbackHeader = null) {
95
- const existing = (await pathExists(filePath)) ? await readFile(filePath, "utf8") : null;
96
- if (existing === null) return { changed: false, removedFile: false };
97
- const computed = removeManagedBlock(existing, begin, end);
98
- if (!computed.changed) return { changed: false, removedFile: false };
99
- if (computed.deleteFile || (emptyFallbackHeader && computed.next.trim() === emptyFallbackHeader.trim())) {
100
- await rm(filePath, { force: true });
101
- return { changed: true, removedFile: true };
102
- }
103
- await writeFile(filePath, computed.next, "utf8");
104
- return { changed: true, removedFile: false };
77
+ function removeManagedBlock(existing, analysis) {
78
+ if (analysis.state === "missing") return { changed: false, next: existing };
79
+ return {
80
+ changed: true,
81
+ next: `${existing.slice(0, analysis.begin)}${existing.slice(analysis.end)}`,
82
+ };
105
83
  }
106
84
 
107
85
  function entrypointDefinitions(projectRoot) {
@@ -125,46 +103,66 @@ function entrypointDefinitions(projectRoot) {
125
103
  ];
126
104
  }
127
105
 
106
+ async function inspectDefinition(file) {
107
+ const info = await pathExists(file.path);
108
+ const existing = info?.isFile() ? await readUtf8FileFatal(file.path, file.relativePath) : null;
109
+ const base = existing ?? file.header;
110
+ const analysis = analyzeManagedBlockText(base, file.begin, file.end, file.block, `${file.relativePath} nimicoding managed block`);
111
+ const next = upsertManagedBlock(base, analysis, file.block);
112
+ return { ...file, existing, base, analysis, next, changed: next !== base };
113
+ }
114
+
115
+ export async function inspectEntrypointIntegration(projectRoot) {
116
+ await preflightManagedProjectPaths(projectRoot);
117
+ const states = [];
118
+ for (const file of entrypointDefinitions(projectRoot)) states.push(await inspectDefinition(file));
119
+ return states;
120
+ }
121
+
128
122
  export async function previewEntrypointIntegration(projectRoot) {
129
- const updates = [];
130
- for (const file of entrypointDefinitions(projectRoot)) {
131
- const existing = (await pathExists(file.path)) ? await readFile(file.path, "utf8") : null;
132
- if (computeTextFileUpdate(existing, file.block, file.begin, file.end, file.header).changed) {
133
- updates.push(file.relativePath);
134
- }
135
- }
136
- return updates;
123
+ return (await inspectEntrypointIntegration(projectRoot)).filter((state) => state.changed).map((state) => state.relativePath);
137
124
  }
138
125
 
139
126
  export async function integrateEntrypoints(projectRoot) {
127
+ const states = await inspectEntrypointIntegration(projectRoot);
140
128
  const updated = [];
141
- for (const file of entrypointDefinitions(projectRoot)) {
142
- if (await upsertTextFile(file.path, file.block, file.begin, file.end, file.header)) {
143
- updated.push(file.relativePath);
144
- }
129
+ for (const state of states) {
130
+ if (!state.changed) continue;
131
+ await writeFile(state.path, state.next, "utf8");
132
+ updated.push(state.relativePath);
145
133
  }
146
134
  return updated;
147
135
  }
148
136
 
149
- export async function previewEntrypointRemoval(projectRoot) {
150
- const updates = [];
137
+ async function inspectEntrypointRemoval(projectRoot) {
138
+ await preflightManagedProjectPaths(projectRoot);
139
+ const states = [];
151
140
  for (const file of entrypointDefinitions(projectRoot)) {
152
- const existing = (await pathExists(file.path)) ? await readFile(file.path, "utf8") : null;
153
- if (existing !== null && removeManagedBlock(existing, file.begin, file.end).changed) {
154
- updates.push(file.relativePath);
141
+ const info = await pathExists(file.path);
142
+ const existing = info?.isFile() ? await readUtf8FileFatal(file.path, file.relativePath) : null;
143
+ if (existing === null) {
144
+ states.push({ ...file, existing, removal: { changed: false, next: "" } });
145
+ continue;
155
146
  }
147
+ const analysis = analyzeManagedBlockText(existing, file.begin, file.end, file.block, `${file.relativePath} nimicoding managed block`);
148
+ const removal = removeManagedBlock(existing, analysis);
149
+ states.push({ ...file, existing, removal });
156
150
  }
157
- return updates;
151
+ return states;
152
+ }
153
+
154
+ export async function previewEntrypointRemoval(projectRoot) {
155
+ return (await inspectEntrypointRemoval(projectRoot)).filter((state) => state.removal.changed).map((state) => state.relativePath);
158
156
  }
159
157
 
160
158
  export async function removeManagedEntrypoints(projectRoot) {
159
+ const states = await inspectEntrypointRemoval(projectRoot);
161
160
  const updatedFiles = [];
162
161
  const removedFiles = [];
163
- for (const file of entrypointDefinitions(projectRoot)) {
164
- const result = await removeManagedTextFile(file.path, file.begin, file.end, file.header.trim());
165
- if (result.changed) {
166
- (result.removedFile ? removedFiles : updatedFiles).push(file.relativePath);
167
- }
162
+ for (const state of states) {
163
+ if (!state.removal.changed) continue;
164
+ await writeFile(state.path, state.removal.next, "utf8");
165
+ updatedFiles.push(state.relativePath);
168
166
  }
169
167
  return { updatedFiles, removedFiles };
170
168
  }
@@ -1,33 +1,157 @@
1
- import { readFile, stat, writeFile } from "node:fs/promises";
1
+ import { lstat, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const SHARED_INODE_FORBIDDEN_PATHS = new Set([
5
+ ".nimi/methodology/authority-authoring.yaml",
6
+ "AGENTS.md",
7
+ "CLAUDE.md",
8
+ ".gitignore",
9
+ ".gitattributes",
10
+ ]);
11
+ const FATAL_UTF8_HOST_PATHS = new Set(["AGENTS.md", "CLAUDE.md", ".gitignore", ".gitattributes"]);
12
+
13
+ const BASE_MANAGED_PATHS = [
14
+ [".nimi", "directory"],
15
+ [".nimi/local", "directory"],
16
+ [".nimi/methodology", "directory"],
17
+ [".nimi/methodology/authority-authoring.yaml", "file"],
18
+ [".nimi/config", "directory"],
19
+ [".nimi/config/spec-generation-inputs.yaml", "file"],
20
+ [".nimi/contracts", "directory"],
21
+ [".nimi/contracts/domain-admission.schema.yaml", "file"],
22
+ ["AGENTS.md", "file"],
23
+ ["CLAUDE.md", "file"],
24
+ [".gitignore", "file"],
25
+ [".gitattributes", "file"],
26
+ ];
27
+
28
+ export class ManagedPathError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "ManagedPathError";
32
+ }
33
+ }
34
+
35
+ export class ManagedTextError extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = "ManagedTextError";
39
+ }
40
+ }
2
41
 
3
42
  export async function pathExists(targetPath) {
4
43
  try {
5
- return await stat(targetPath);
44
+ return await lstat(targetPath);
45
+ } catch (error) {
46
+ if (error?.code === "ENOENT") return null;
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ function portable(value) {
52
+ return value.split(path.sep).join(path.posix.sep);
53
+ }
54
+
55
+ async function inspectManagedPath(projectRoot, relativePath, expectedType) {
56
+ const components = relativePath.split("/");
57
+ let current = projectRoot;
58
+ for (let index = 0; index < components.length; index += 1) {
59
+ current = path.join(current, components[index]);
60
+ let info;
61
+ try {
62
+ info = await lstat(current);
63
+ } catch (error) {
64
+ if (error?.code === "ENOENT") return;
65
+ throw new ManagedPathError(`cannot inspect managed path ${portable(path.relative(projectRoot, current))}: ${error.message}`);
66
+ }
67
+ const ref = portable(path.relative(projectRoot, current));
68
+ if (info.isSymbolicLink()) throw new ManagedPathError(`managed path boundary refuses symbolic link: ${ref}`);
69
+ const isTarget = index === components.length - 1;
70
+ if (!isTarget && !info.isDirectory()) throw new ManagedPathError(`managed path ancestor must be a directory: ${ref}`);
71
+ if (isTarget && expectedType === "directory" && !info.isDirectory()) throw new ManagedPathError(`managed path must be a directory: ${ref}`);
72
+ if (isTarget && expectedType === "file" && !info.isFile()) throw new ManagedPathError(`managed path must be a regular file: ${ref}`);
73
+ if (isTarget && info.isFile() && SHARED_INODE_FORBIDDEN_PATHS.has(relativePath) && info.nlink > 1) {
74
+ throw new ManagedPathError(`managed regular file must not share an inode: ${ref}`);
75
+ }
76
+ if (isTarget && info.isFile() && FATAL_UTF8_HOST_PATHS.has(relativePath)) await readUtf8FileFatal(current, ref);
77
+ }
78
+ }
79
+
80
+ export async function preflightManagedProjectPaths(projectRoot, options = {}) {
81
+ const absoluteRoot = path.resolve(projectRoot);
82
+ let rootInfo;
83
+ try {
84
+ rootInfo = await lstat(absoluteRoot);
85
+ } catch (error) {
86
+ throw new ManagedPathError(`cannot inspect project root: ${error.message}`);
87
+ }
88
+ if (rootInfo.isSymbolicLink()) throw new ManagedPathError("managed path boundary refuses symbolic-link project root");
89
+ if (!rootInfo.isDirectory()) throw new ManagedPathError("project root must be a directory");
90
+ const specs = options.includeSpec
91
+ ? [...BASE_MANAGED_PATHS, [".nimi/spec", "directory"]]
92
+ : BASE_MANAGED_PATHS;
93
+ for (const [relativePath, expectedType] of specs) {
94
+ await inspectManagedPath(absoluteRoot, relativePath, expectedType);
95
+ }
96
+ return true;
97
+ }
98
+
99
+ export async function readUtf8FileFatal(filePath, label = filePath) {
100
+ const bytes = await readFile(filePath);
101
+ try {
102
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
6
103
  } catch {
7
- return null;
104
+ throw new ManagedTextError(`managed host text is not valid UTF-8: ${label}`);
8
105
  }
9
106
  }
10
107
 
11
108
  export async function readTextIfFile(filePath) {
12
109
  const info = await pathExists(filePath);
13
- if (!info || !info.isFile()) {
14
- return null;
15
- }
16
-
110
+ if (!info || !info.isFile() || info.isSymbolicLink()) return null;
17
111
  return readFile(filePath, "utf8");
18
112
  }
19
113
 
20
- export async function appendGitignoreEntries(gitignorePath, entries) {
21
- const existing = (await pathExists(gitignorePath))
22
- ? await readFile(gitignorePath, "utf8")
23
- : "";
24
- const missing = entries.filter((entry) => !existing.includes(entry));
114
+ export function normalizeTextToLf(text) {
115
+ return String(text).replace(/\r\n?/gu, "\n");
116
+ }
25
117
 
26
- if (missing.length === 0) {
27
- return false;
28
- }
118
+ function exactTextLines(text) {
119
+ return String(text ?? "")
120
+ .split(/\n/u)
121
+ .map((line) => line.endsWith("\r") ? line.slice(0, -1) : line);
122
+ }
123
+
124
+ export function hasExactTextLine(text, entry) {
125
+ return exactTextLines(text).includes(entry);
126
+ }
127
+
128
+ function effectiveGitignoreRuleLines(text) {
129
+ return String(text ?? "")
130
+ .split(/\n/u)
131
+ .map((line) => line.endsWith("\r") ? line.slice(0, -1) : line)
132
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
133
+ }
134
+
135
+ export function hasExactGitignoreRule(text, entry) {
136
+ return effectiveGitignoreRuleLines(text).at(-1) === entry;
137
+ }
29
138
 
139
+ export async function appendGitignoreEntries(gitignorePath, entries) {
140
+ const existingInfo = await pathExists(gitignorePath);
141
+ const existing = existingInfo?.isFile() ? await readUtf8FileFatal(gitignorePath, ".gitignore") : "";
142
+ const missing = entries.filter((entry) => !hasExactGitignoreRule(existing, entry));
143
+ if (missing.length === 0) return false;
30
144
  const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
31
145
  await writeFile(gitignorePath, `${existing}${prefix}${missing.join("\n")}\n`, "utf8");
32
146
  return true;
33
147
  }
148
+
149
+ export async function appendGitattributesEntries(gitattributesPath, entries) {
150
+ const existingInfo = await pathExists(gitattributesPath);
151
+ const existing = existingInfo?.isFile() ? await readUtf8FileFatal(gitattributesPath, ".gitattributes") : "";
152
+ const missing = entries.filter((entry) => !hasExactTextLine(existing, entry));
153
+ if (missing.length === 0) return false;
154
+ const prefix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
155
+ await writeFile(gitattributesPath, `${existing}${prefix}${missing.join("\n")}\n`, "utf8");
156
+ return true;
157
+ }